Merge pull request #755 from jeremymcs/feat/vscode-marketplace
feat(vscode): marketplace-ready files for VS Code extension publishing
This commit is contained in:
commit
889a2ee137
15 changed files with 4475 additions and 162 deletions
|
|
@ -10,12 +10,19 @@ import {
|
|||
import type { BgProcess, OutputDigest, OutputLine, GetOutputOptions } from "./types.js";
|
||||
import {
|
||||
ERROR_PATTERNS,
|
||||
ERROR_PATTERN_UNION,
|
||||
WARNING_PATTERN_UNION,
|
||||
READINESS_PATTERN_UNION,
|
||||
BUILD_COMPLETE_PATTERN_UNION,
|
||||
TEST_RESULT_PATTERN_UNION,
|
||||
WARNING_PATTERNS,
|
||||
URL_PATTERN,
|
||||
PORT_PATTERN,
|
||||
PORT_PATTERN_SOURCE,
|
||||
READINESS_PATTERNS,
|
||||
BUILD_COMPLETE_PATTERNS,
|
||||
TEST_RESULT_PATTERNS,
|
||||
LINE_DEDUP_MAX,
|
||||
} from "./types.js";
|
||||
import { addEvent, pushAlert } from "./process-manager.js";
|
||||
import { transitionToReady } from "./readiness-detector.js";
|
||||
|
|
@ -24,8 +31,8 @@ import { formatUptime, formatTimeAgo } from "./utilities.js";
|
|||
// ── Output Analysis ────────────────────────────────────────────────────────
|
||||
|
||||
export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void {
|
||||
// Error detection
|
||||
if (ERROR_PATTERNS.some(p => p.test(line))) {
|
||||
// Error detection — single union regex instead of .some(p => p.test(line))
|
||||
if (ERROR_PATTERN_UNION.test(line)) {
|
||||
bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length
|
||||
if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50);
|
||||
|
||||
|
|
@ -40,8 +47,8 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
|
|||
}
|
||||
}
|
||||
|
||||
// Warning detection
|
||||
if (WARNING_PATTERNS.some(p => p.test(line))) {
|
||||
// Warning detection — single union regex
|
||||
if (WARNING_PATTERN_UNION.test(line)) {
|
||||
bg.recentWarnings.push(line.trim().slice(0, 200));
|
||||
if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50);
|
||||
}
|
||||
|
|
@ -56,9 +63,10 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
|
|||
}
|
||||
}
|
||||
|
||||
// Port extraction
|
||||
// Port extraction — PORT_PATTERN has /g flag so must be re-created per call
|
||||
// Use PORT_PATTERN_SOURCE (string) to avoid re-parsing the literal each time
|
||||
const portRe = new RegExp(PORT_PATTERN_SOURCE, "gi");
|
||||
let portMatch: RegExpExecArray | null;
|
||||
const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags);
|
||||
while ((portMatch = portRe.exec(line)) !== null) {
|
||||
const port = parseInt(portMatch[1], 10);
|
||||
if (port > 0 && port <= 65535 && !bg.ports.includes(port)) {
|
||||
|
|
@ -71,7 +79,7 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
|
|||
}
|
||||
}
|
||||
|
||||
// Readiness detection
|
||||
// Readiness detection — single union regex
|
||||
if (bg.status === "starting") {
|
||||
// Check custom ready pattern first
|
||||
if (bg.readyPattern) {
|
||||
|
|
@ -83,14 +91,14 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
|
|||
}
|
||||
|
||||
// Check built-in readiness patterns
|
||||
if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) {
|
||||
if (bg.status === "starting" && READINESS_PATTERN_UNION.test(line)) {
|
||||
transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery detection: if we were in error and see a success pattern
|
||||
if (bg.status === "error") {
|
||||
if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) {
|
||||
if (READINESS_PATTERN_UNION.test(line) || BUILD_COMPLETE_PATTERN_UNION.test(line)) {
|
||||
bg.status = "ready";
|
||||
bg.recentErrors = [];
|
||||
addEvent(bg, { type: "recovered", detail: "Process recovered from error state" });
|
||||
|
|
@ -98,10 +106,22 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
|
|||
}
|
||||
}
|
||||
|
||||
// Dedup tracking
|
||||
// Dedup tracking — evict oldest entry when map exceeds LINE_DEDUP_MAX (LRU via Map insertion order)
|
||||
bg.totalRawLines++;
|
||||
const lineHash = line.trim().slice(0, 100);
|
||||
bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1);
|
||||
const existing = bg.lineDedup.get(lineHash);
|
||||
if (existing !== undefined) {
|
||||
// Re-insert to update insertion order (move to tail = most recent)
|
||||
bg.lineDedup.delete(lineHash);
|
||||
bg.lineDedup.set(lineHash, existing + 1);
|
||||
} else {
|
||||
if (bg.lineDedup.size >= LINE_DEDUP_MAX) {
|
||||
// Evict oldest entry (Map iteration order = insertion order = LRU at head)
|
||||
const oldest = bg.lineDedup.keys().next().value;
|
||||
if (oldest !== undefined) bg.lineDedup.delete(oldest);
|
||||
}
|
||||
bg.lineDedup.set(lineHash, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Digest Generation ──────────────────────────────────────────────────────
|
||||
|
|
@ -154,12 +174,12 @@ export function getHighlights(bg: BgProcess, maxLines: number = 15): string[] {
|
|||
for (let i = 0; i < bg.output.length; i++) {
|
||||
const entry = bg.output[i];
|
||||
let score = 0;
|
||||
if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10;
|
||||
if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5;
|
||||
if (ERROR_PATTERN_UNION.test(entry.line)) score += 10;
|
||||
if (WARNING_PATTERN_UNION.test(entry.line)) score += 5;
|
||||
if (URL_PATTERN.test(entry.line)) score += 3;
|
||||
if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8;
|
||||
if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7;
|
||||
if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6;
|
||||
if (READINESS_PATTERN_UNION.test(entry.line)) score += 8;
|
||||
if (TEST_RESULT_PATTERN_UNION.test(entry.line)) score += 7;
|
||||
if (BUILD_COMPLETE_PATTERN_UNION.test(entry.line)) score += 6;
|
||||
// Boost recent lines so highlights favor fresh output over stale
|
||||
if (i >= bg.output.length - 50) score += 2;
|
||||
if (score > 0) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export function setPendingAlerts(alerts: string[]): void {
|
|||
|
||||
export function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void {
|
||||
bg.output.push({ stream, line, ts: Date.now() });
|
||||
if (stream === "stdout") bg.stdoutLineCount++;
|
||||
else bg.stderrLineCount++;
|
||||
if (bg.output.length > MAX_BUFFER_LINES) {
|
||||
const excess = bg.output.length - MAX_BUFFER_LINES;
|
||||
bg.output.splice(0, excess);
|
||||
|
|
@ -60,8 +62,6 @@ export function pushAlert(bg: BgProcess, message: string): void {
|
|||
}
|
||||
|
||||
export function getInfo(p: BgProcess): BgProcessInfo {
|
||||
const stdoutLines = p.output.filter(l => l.stream === "stdout").length;
|
||||
const stderrLines = p.output.filter(l => l.stream === "stderr").length;
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
|
|
@ -72,8 +72,8 @@ export function getInfo(p: BgProcess): BgProcessInfo {
|
|||
exitCode: p.exitCode,
|
||||
signal: p.signal,
|
||||
outputLines: p.output.length,
|
||||
stdoutLines,
|
||||
stderrLines,
|
||||
stdoutLines: p.stdoutLineCount,
|
||||
stderrLines: p.stderrLineCount,
|
||||
status: p.status,
|
||||
processType: p.processType,
|
||||
ports: p.ports,
|
||||
|
|
@ -161,6 +161,8 @@ export function startProcess(opts: StartOptions): BgProcess {
|
|||
commandHistory: [],
|
||||
lineDedup: new Map(),
|
||||
totalRawLines: 0,
|
||||
stdoutLineCount: 0,
|
||||
stderrLineCount: 0,
|
||||
envKeys: Object.keys(opts.env || {}),
|
||||
restartCount: 0,
|
||||
startConfig: {
|
||||
|
|
|
|||
|
|
@ -90,10 +90,14 @@ export interface BgProcess {
|
|||
lastWarningCount: number;
|
||||
/** Command history for shell-type sessions */
|
||||
commandHistory: string[];
|
||||
/** Dedup tracker: hash → count of repeated lines */
|
||||
/** Dedup tracker: hash → count of repeated lines (capped at LINE_DEDUP_MAX entries) */
|
||||
lineDedup: Map<string, number>;
|
||||
/** Total raw lines (before dedup) for token savings calc */
|
||||
totalRawLines: number;
|
||||
/** Tracked stdout line count (incremented in addOutputLine, avoids O(n) filter) */
|
||||
stdoutLineCount: number;
|
||||
/** Tracked stderr line count (incremented in addOutputLine, avoids O(n) filter) */
|
||||
stderrLineCount: number;
|
||||
/** Env snapshot (keys only, no values for security) */
|
||||
envKeys: string[];
|
||||
/** Restart count */
|
||||
|
|
@ -163,6 +167,8 @@ export interface ProcessManifest {
|
|||
export const MAX_BUFFER_LINES = 5000;
|
||||
export const MAX_EVENTS = 200;
|
||||
export const DEAD_PROCESS_TTL = 10 * 60 * 1000;
|
||||
/** Maximum unique entries in the per-process lineDedup Map before LRU eviction. */
|
||||
export const LINE_DEDUP_MAX = 500;
|
||||
export const PORT_PROBE_TIMEOUT = 500;
|
||||
export const READY_POLL_INTERVAL = 250;
|
||||
export const DEFAULT_READY_TIMEOUT = 30000;
|
||||
|
|
@ -249,3 +255,29 @@ export const BUILD_COMPLETE_PATTERNS: RegExp[] = [
|
|||
/webpack\s+\d+\.\d+/i,
|
||||
/bundle\s+(?:is\s+)?ready/i,
|
||||
];
|
||||
|
||||
// ── Compiled union regexes (single-pass alternatives to .some(p => p.test(line))) ──
|
||||
// Built once at module load — eliminates per-line RegExp construction overhead.
|
||||
|
||||
export const ERROR_PATTERN_UNION = new RegExp(
|
||||
ERROR_PATTERNS.map(p => p.source).join("|"),
|
||||
"i",
|
||||
);
|
||||
export const WARNING_PATTERN_UNION = new RegExp(
|
||||
WARNING_PATTERNS.map(p => p.source).join("|"),
|
||||
"i",
|
||||
);
|
||||
export const READINESS_PATTERN_UNION = new RegExp(
|
||||
READINESS_PATTERNS.map(p => p.source).join("|"),
|
||||
"i",
|
||||
);
|
||||
export const BUILD_COMPLETE_PATTERN_UNION = new RegExp(
|
||||
BUILD_COMPLETE_PATTERNS.map(p => p.source).join("|"),
|
||||
"i",
|
||||
);
|
||||
export const TEST_RESULT_PATTERN_UNION = new RegExp(
|
||||
TEST_RESULT_PATTERNS.map(p => p.source).join("|"),
|
||||
"i",
|
||||
);
|
||||
/** PORT_PATTERN compiled once for reuse in analyzeLine (needs exec, so must be re-created per call with /g) */
|
||||
export const PORT_PATTERN_SOURCE = PORT_PATTERN.source;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Standalone module: only imports node:child_process and node:path.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFileSync, execFile } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -32,10 +32,23 @@ const EXEC_OPTS = {
|
|||
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
/** Synchronous git — used where sequential control flow is required (fallback paths). */
|
||||
function gitSync(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, { ...EXEC_OPTS, cwd }).trim();
|
||||
}
|
||||
|
||||
/** Async git — returns stdout on success, empty string on any error. */
|
||||
function gitAsync(args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"git",
|
||||
args,
|
||||
{ encoding: "utf-8", timeout: 5000, cwd },
|
||||
(err, stdout) => resolve(err ? "" : stdout.trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function splitLines(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
|
|
@ -49,6 +62,8 @@ function splitLines(output: string): string[] {
|
|||
* Returns recently-changed file paths, deduplicated and sorted by recency
|
||||
* (most recent first). Combines committed diffs, staged changes, and
|
||||
* unstaged/untracked files from `git status`.
|
||||
*
|
||||
* The three git queries (log, diff --cached, status) run concurrently.
|
||||
*/
|
||||
export async function getRecentlyChangedFiles(
|
||||
cwd: string,
|
||||
|
|
@ -59,40 +74,23 @@ export async function getRecentlyChangedFiles(
|
|||
const dir = resolve(cwd);
|
||||
|
||||
try {
|
||||
// 1. Committed changes in the last N commits (or since sinceDays)
|
||||
let committedFiles: string[] = [];
|
||||
try {
|
||||
const days = Math.max(1, Math.floor(Number(sinceDays)));
|
||||
if (!Number.isFinite(days)) throw new Error("invalid sinceDays");
|
||||
const raw = git(["log", "--diff-filter=ACMR", "--name-only", "--pretty=format:", `--since=${days} days ago`], dir);
|
||||
committedFiles = splitLines(raw);
|
||||
} catch {
|
||||
// Fallback: use HEAD~10
|
||||
try {
|
||||
const raw = git(["diff", "--name-only", "HEAD~10"], dir);
|
||||
committedFiles = splitLines(raw);
|
||||
} catch {
|
||||
// Shallow clone or <10 commits — ignore
|
||||
}
|
||||
}
|
||||
const days = Math.max(1, Math.floor(Number(sinceDays)));
|
||||
if (!Number.isFinite(days)) throw new Error("invalid sinceDays");
|
||||
|
||||
// 2. Staged changes
|
||||
let stagedFiles: string[] = [];
|
||||
try {
|
||||
const raw = git(["diff", "--cached", "--name-only"], dir);
|
||||
stagedFiles = splitLines(raw);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Run all three queries concurrently — they read independent git state
|
||||
const [logRaw, stagedRaw, statusRaw] = await Promise.all([
|
||||
// 1. Committed changes since N days ago (fallback to HEAD~10 on error)
|
||||
gitAsync(["log", "--diff-filter=ACMR", "--name-only", "--pretty=format:", `--since=${days} days ago`], dir)
|
||||
.then((out) => out || gitAsync(["diff", "--name-only", "HEAD~10"], dir)),
|
||||
// 2. Staged changes
|
||||
gitAsync(["diff", "--cached", "--name-only"], dir),
|
||||
// 3. Unstaged / untracked
|
||||
gitAsync(["status", "--porcelain"], dir),
|
||||
]);
|
||||
|
||||
// 3. Unstaged / untracked via porcelain status
|
||||
let statusFiles: string[] = [];
|
||||
try {
|
||||
const raw = git(["status", "--porcelain"], dir);
|
||||
statusFiles = splitLines(raw).map((line) => line.slice(3)); // strip XY + space
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const committedFiles = splitLines(logRaw);
|
||||
const stagedFiles = splitLines(stagedRaw);
|
||||
const statusFiles = splitLines(statusRaw).map((line) => line.slice(3)); // strip XY + space
|
||||
|
||||
// Deduplicate, preserving insertion order (most-recent-first: status → staged → committed)
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -113,6 +111,9 @@ export async function getRecentlyChangedFiles(
|
|||
|
||||
/**
|
||||
* Returns richer change metadata: change type and approximate line counts.
|
||||
*
|
||||
* The three git queries (diff --cached --numstat, diff --numstat, status --porcelain)
|
||||
* run concurrently — they read independent git state.
|
||||
*/
|
||||
export async function getChangedFilesWithContext(
|
||||
cwd: string,
|
||||
|
|
@ -120,6 +121,13 @@ export async function getChangedFilesWithContext(
|
|||
const dir = resolve(cwd);
|
||||
|
||||
try {
|
||||
// Run all three queries concurrently
|
||||
const [cachedNumstat, unstagedNumstat, statusRaw] = await Promise.all([
|
||||
gitAsync(["diff", "--cached", "--numstat"], dir),
|
||||
gitAsync(["diff", "--numstat"], dir),
|
||||
gitAsync(["status", "--porcelain"], dir),
|
||||
]);
|
||||
|
||||
const result: ChangedFileInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
|
|
@ -131,57 +139,42 @@ export async function getChangedFilesWithContext(
|
|||
};
|
||||
|
||||
// 1. Staged files with numstat
|
||||
try {
|
||||
const numstat = git(["diff", "--cached", "--numstat"], dir);
|
||||
for (const line of splitLines(numstat)) {
|
||||
const [added, deleted, filePath] = line.split("\t");
|
||||
if (!filePath) continue;
|
||||
const lines =
|
||||
added === "-" || deleted === "-"
|
||||
? undefined
|
||||
: Number(added) + Number(deleted);
|
||||
add({ path: filePath, changeType: "staged", linesChanged: lines });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
for (const line of splitLines(cachedNumstat)) {
|
||||
const [added, deleted, filePath] = line.split("\t");
|
||||
if (!filePath) continue;
|
||||
const lines =
|
||||
added === "-" || deleted === "-"
|
||||
? undefined
|
||||
: Number(added) + Number(deleted);
|
||||
add({ path: filePath, changeType: "staged", linesChanged: lines });
|
||||
}
|
||||
|
||||
// 2. Unstaged modifications with numstat
|
||||
try {
|
||||
const numstat = git(["diff", "--numstat"], dir);
|
||||
for (const line of splitLines(numstat)) {
|
||||
const [added, deleted, filePath] = line.split("\t");
|
||||
if (!filePath) continue;
|
||||
const lines =
|
||||
added === "-" || deleted === "-"
|
||||
? undefined
|
||||
: Number(added) + Number(deleted);
|
||||
add({ path: filePath, changeType: "modified", linesChanged: lines });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
for (const line of splitLines(unstagedNumstat)) {
|
||||
const [added, deleted, filePath] = line.split("\t");
|
||||
if (!filePath) continue;
|
||||
const lines =
|
||||
added === "-" || deleted === "-"
|
||||
? undefined
|
||||
: Number(added) + Number(deleted);
|
||||
add({ path: filePath, changeType: "modified", linesChanged: lines });
|
||||
}
|
||||
|
||||
// 3. Untracked / deleted from porcelain status
|
||||
try {
|
||||
const raw = git(["status", "--porcelain"], dir);
|
||||
for (const line of splitLines(raw)) {
|
||||
const code = line.slice(0, 2);
|
||||
const filePath = line.slice(3);
|
||||
if (seen.has(filePath)) continue;
|
||||
for (const line of splitLines(statusRaw)) {
|
||||
const code = line.slice(0, 2);
|
||||
const filePath = line.slice(3);
|
||||
if (seen.has(filePath)) continue;
|
||||
|
||||
if (code.includes("?")) {
|
||||
add({ path: filePath, changeType: "added" });
|
||||
} else if (code.includes("D")) {
|
||||
add({ path: filePath, changeType: "deleted" });
|
||||
} else if (code.includes("A")) {
|
||||
add({ path: filePath, changeType: "added" });
|
||||
} else {
|
||||
add({ path: filePath, changeType: "modified" });
|
||||
}
|
||||
if (code.includes("?")) {
|
||||
add({ path: filePath, changeType: "added" });
|
||||
} else if (code.includes("D")) {
|
||||
add({ path: filePath, changeType: "deleted" });
|
||||
} else if (code.includes("A")) {
|
||||
add({ path: filePath, changeType: "added" });
|
||||
} else {
|
||||
add({ path: filePath, changeType: "modified" });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -103,10 +103,21 @@ async function indexSlice(basePath: string, milestoneId: string, sliceId: string
|
|||
};
|
||||
}
|
||||
|
||||
export async function indexWorkspace(basePath: string): Promise<GSDWorkspaceIndex> {
|
||||
export interface IndexWorkspaceOptions {
|
||||
/**
|
||||
* When true, run validatePlanBoundary and validateCompleteBoundary for each slice.
|
||||
* Skipped by default — validation is expensive (content analysis) and only needed
|
||||
* for explicit doctor/audit flows. The /gsd status dashboard and scope pickers
|
||||
* don't need the full issue list.
|
||||
*/
|
||||
validate?: boolean;
|
||||
}
|
||||
|
||||
export async function indexWorkspace(basePath: string, opts: IndexWorkspaceOptions = {}): Promise<GSDWorkspaceIndex> {
|
||||
const milestoneIds = findMilestoneIds(basePath);
|
||||
const milestones: WorkspaceMilestoneTarget[] = [];
|
||||
const validationIssues: ValidationIssue[] = [];
|
||||
const runValidation = opts.validate === true;
|
||||
|
||||
for (const milestoneId of milestoneIds) {
|
||||
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP") ?? undefined;
|
||||
|
|
@ -118,11 +129,27 @@ export async function indexWorkspace(basePath: string): Promise<GSDWorkspaceInde
|
|||
if (roadmapContent) {
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
title = titleFromRoadmapHeader(roadmapContent, milestoneId);
|
||||
for (const slice of roadmap.slices) {
|
||||
const indexedSlice = await indexSlice(basePath, milestoneId, slice.id, slice.title, slice.done);
|
||||
|
||||
// Parallelise all per-slice I/O: indexSlice + (optional) validation calls run concurrently.
|
||||
// Order is preserved via Promise.all on an array built from roadmap.slices.
|
||||
const sliceResults = await Promise.all(
|
||||
roadmap.slices.map(async (slice) => {
|
||||
if (runValidation) {
|
||||
const [indexedSlice, planIssues, completeIssues] = await Promise.all([
|
||||
indexSlice(basePath, milestoneId, slice.id, slice.title, slice.done),
|
||||
validatePlanBoundary(basePath, milestoneId, slice.id),
|
||||
validateCompleteBoundary(basePath, milestoneId, slice.id),
|
||||
]);
|
||||
return { indexedSlice, issues: [...planIssues, ...completeIssues] };
|
||||
}
|
||||
const indexedSlice = await indexSlice(basePath, milestoneId, slice.id, slice.title, slice.done);
|
||||
return { indexedSlice, issues: [] as ValidationIssue[] };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { indexedSlice, issues } of sliceResults) {
|
||||
slices.push(indexedSlice);
|
||||
validationIssues.push(...await validatePlanBoundary(basePath, milestoneId, slice.id));
|
||||
validationIssues.push(...await validateCompleteBoundary(basePath, milestoneId, slice.id));
|
||||
validationIssues.push(...issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +200,8 @@ export async function listDoctorScopeSuggestions(basePath: string): Promise<Arra
|
|||
}
|
||||
|
||||
export async function getSuggestedNextCommands(basePath: string): Promise<string[]> {
|
||||
const index = await indexWorkspace(basePath);
|
||||
// Run validation here since we surface a /gsd doctor audit hint when issues exist.
|
||||
const index = await indexWorkspace(basePath, { validate: true });
|
||||
const scope = index.active.milestoneId && index.active.sliceId
|
||||
? `${index.active.milestoneId}/${index.active.sliceId}`
|
||||
: index.active.milestoneId;
|
||||
|
|
|
|||
3
vscode-extension/.gitignore
vendored
Normal file
3
vscode-extension/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
node_modules/
|
||||
*.vsix
|
||||
9
vscode-extension/.vscodeignore
Normal file
9
vscode-extension/.vscodeignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.vscode/**
|
||||
.vscode-test/**
|
||||
src/**
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
**/*.ts
|
||||
!dist/**
|
||||
node_modules/**
|
||||
**/*.map
|
||||
11
vscode-extension/CHANGELOG.md
Normal file
11
vscode-extension/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Changelog
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
Initial release.
|
||||
|
||||
- Full RPC client — spawns `gsd --mode rpc`, JSON line framing, all 25 RPC commands
|
||||
- Sidebar dashboard — connection status, model info, thinking level, token usage, cost, quick actions
|
||||
- Chat participant — `@gsd` in VS Code Chat with streaming responses
|
||||
- 15 commands with keyboard shortcuts
|
||||
- Auto-start and auto-compaction configuration
|
||||
21
vscode-extension/LICENSE
Normal file
21
vscode-extension/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lex Christopherson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
91
vscode-extension/README.md
Normal file
91
vscode-extension/README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# GSD-2 — VS Code Extension
|
||||
|
||||
Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd` in VS Code Chat, and monitor your agent from a sidebar dashboard — all without leaving the editor.
|
||||
|
||||
## Requirements
|
||||
|
||||
GSD must be installed before activating this extension:
|
||||
|
||||
```bash
|
||||
npm install -g gsd-pi
|
||||
```
|
||||
|
||||
Node.js ≥ 20.6.0 and Git are required.
|
||||
|
||||
## Features
|
||||
|
||||
### Sidebar Dashboard
|
||||
|
||||
Click the GSD icon in the Activity Bar to open the agent dashboard. It shows:
|
||||
|
||||
- Connection status (connected / disconnected)
|
||||
- Active model and provider
|
||||
- Thinking level
|
||||
- Token usage and session cost
|
||||
- Quick action buttons: Start, Stop, New Session, Compact, Abort
|
||||
|
||||
### Chat Integration (`@gsd`)
|
||||
|
||||
Use `@gsd` in VS Code Chat (`Ctrl+Shift+I`) to send messages to the agent:
|
||||
|
||||
```
|
||||
@gsd refactor the auth module to use JWT
|
||||
@gsd /gsd auto
|
||||
@gsd what's the current milestone status?
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
All commands are accessible via `Ctrl+Shift+P`:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **GSD: Start Agent** | Connect to the GSD agent |
|
||||
| **GSD: Stop Agent** | Disconnect the agent |
|
||||
| **GSD: New Session** | Start a fresh conversation |
|
||||
| **GSD: Send Message** | Send a message to the agent |
|
||||
| **GSD: Abort Current Operation** | Interrupt the current operation |
|
||||
| **GSD: Steer Agent** | Send a steering message mid-operation |
|
||||
| **GSD: Switch Model** | Pick a model from QuickPick |
|
||||
| **GSD: Cycle Model** | Rotate to the next configured model |
|
||||
| **GSD: Set Thinking Level** | Choose off / low / medium / high |
|
||||
| **GSD: Cycle Thinking Level** | Rotate through thinking levels |
|
||||
| **GSD: Compact Context** | Manually trigger context compaction |
|
||||
| **GSD: Export Conversation as HTML** | Save the session as HTML |
|
||||
| **GSD: Show Session Stats** | Display token usage and cost |
|
||||
| **GSD: Run Bash Command** | Execute a shell command via the agent |
|
||||
| **GSD: List Available Commands** | Browse and run GSD slash commands |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Command |
|
||||
|----------|---------|
|
||||
| `Ctrl+Shift+G Ctrl+Shift+N` | New Session |
|
||||
| `Ctrl+Shift+G Ctrl+Shift+M` | Cycle Model |
|
||||
| `Ctrl+Shift+G Ctrl+Shift+T` | Cycle Thinking Level |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `gsd.binaryPath` | `"gsd"` | Path to the GSD binary if not on PATH |
|
||||
| `gsd.autoStart` | `false` | Start the agent automatically when the extension activates |
|
||||
| `gsd.autoCompaction` | `true` | Enable automatic context compaction |
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install GSD: `npm install -g gsd-pi`
|
||||
2. Install this extension
|
||||
3. Open a project folder in VS Code
|
||||
4. `Ctrl+Shift+P` → **GSD: Start Agent**
|
||||
5. Use `@gsd` in Chat or the sidebar to interact with the agent
|
||||
|
||||
## How It Works
|
||||
|
||||
The extension spawns `gsd --mode rpc` in the background and communicates over JSON-RPC via stdin/stdout. All 25 RPC commands are supported, including streaming events for real-time sidebar updates.
|
||||
|
||||
## Links
|
||||
|
||||
- [GSD Documentation](https://github.com/gsd-build/gsd-2/tree/main/docs)
|
||||
- [Getting Started](https://github.com/gsd-build/gsd-2/blob/main/docs/getting-started.md)
|
||||
- [Issue Tracker](https://github.com/gsd-build/gsd-2/issues)
|
||||
BIN
vscode-extension/logo.jpg
Normal file
BIN
vscode-extension/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
3964
vscode-extension/package-lock.json
generated
3964
vscode-extension/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,34 @@
|
|||
{
|
||||
"name": "gsd-vscode",
|
||||
"displayName": "GSD - Get Shit Done",
|
||||
"description": "VS Code integration for the GSD coding agent",
|
||||
"publisher": "gsd-build",
|
||||
"name": "gsd-2",
|
||||
"displayName": "GSD-2",
|
||||
"description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, and 15 commands",
|
||||
"publisher": "FluxLabs",
|
||||
"version": "0.1.0",
|
||||
"icon": "logo.jpg",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/gsd-build/gsd-2"
|
||||
},
|
||||
"homepage": "https://github.com/gsd-build/gsd-2/blob/main/vscode-extension/README.md",
|
||||
"bugs": {
|
||||
"url": "https://github.com/gsd-build/gsd-2/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agent",
|
||||
"coding",
|
||||
"gsd",
|
||||
"chat",
|
||||
"automation",
|
||||
"claude",
|
||||
"openai",
|
||||
"llm"
|
||||
],
|
||||
"galleryBanner": {
|
||||
"color": "#1a1a2e",
|
||||
"theme": "dark"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.95.0"
|
||||
},
|
||||
|
|
@ -119,7 +143,7 @@
|
|||
"id": "gsd.agent",
|
||||
"name": "gsd",
|
||||
"fullName": "GSD Agent",
|
||||
"description": "Get Shit Done coding agent",
|
||||
"description": "GSD-2 coding agent",
|
||||
"isSticky": true
|
||||
}
|
||||
],
|
||||
|
|
@ -147,10 +171,12 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"package": "vsce package"
|
||||
"package": "vsce package",
|
||||
"publish": "vsce publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.95.0",
|
||||
"@vscode/vsce": "^3.7.1",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,21 +15,36 @@ export function registerChatParticipant(
|
|||
response: vscode.ChatResponseStream,
|
||||
token: vscode.CancellationToken,
|
||||
) => {
|
||||
// Auto-start the agent if not connected
|
||||
if (!client.isConnected) {
|
||||
response.markdown("GSD agent is not running. Use the **GSD: Start Agent** command first.");
|
||||
return;
|
||||
response.progress("Starting GSD agent...");
|
||||
try {
|
||||
await client.start();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
response.markdown(`**Failed to start GSD agent:** ${msg}\n\nMake sure \`gsd\` is installed (\`npm install -g gsd-pi\`) and try again.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const message = request.prompt;
|
||||
if (!message.trim()) {
|
||||
// Build the full message, injecting any #file references
|
||||
let message = request.prompt.trim();
|
||||
if (!message) {
|
||||
response.markdown("Please provide a message.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track streaming events while the prompt executes
|
||||
const fileContext = await buildFileContext(request);
|
||||
if (fileContext) {
|
||||
message = `${fileContext}\n\n${message}`;
|
||||
}
|
||||
|
||||
// Track streaming state
|
||||
let agentDone = false;
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
const filesWritten: string[] = [];
|
||||
const filesRead: string[] = [];
|
||||
|
||||
const eventHandler = (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
|
|
@ -40,44 +55,18 @@ export function registerChatParticipant(
|
|||
case "tool_execution_start": {
|
||||
const toolName = event.toolName as string;
|
||||
const toolInput = event.toolInput as Record<string, unknown> | undefined;
|
||||
const detail = describeToolCall(toolName, toolInput);
|
||||
response.progress(detail);
|
||||
|
||||
let detail = `Running tool: ${toolName}`;
|
||||
|
||||
// Show relevant parameters for common tools
|
||||
if (toolInput) {
|
||||
if (toolName === "Read" && toolInput.file_path) {
|
||||
detail = `Reading: ${toolInput.file_path}`;
|
||||
} else if (toolName === "Write" && toolInput.file_path) {
|
||||
detail = `Writing: ${toolInput.file_path}`;
|
||||
} else if (toolName === "Edit" && toolInput.file_path) {
|
||||
detail = `Editing: ${toolInput.file_path}`;
|
||||
} else if (toolName === "Bash" && toolInput.command) {
|
||||
const cmd = String(toolInput.command);
|
||||
detail = `Running: $ ${cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd}`;
|
||||
} else if (toolName === "Glob" && toolInput.pattern) {
|
||||
detail = `Searching: ${toolInput.pattern}`;
|
||||
} else if (toolName === "Grep" && toolInput.pattern) {
|
||||
detail = `Grep: ${toolInput.pattern}`;
|
||||
// Track file paths for anchors
|
||||
if (toolInput?.file_path) {
|
||||
const fp = String(toolInput.file_path);
|
||||
if (toolName === "Write" || toolName === "Edit") {
|
||||
if (!filesWritten.includes(fp)) filesWritten.push(fp);
|
||||
} else if (toolName === "Read") {
|
||||
if (!filesRead.includes(fp)) filesRead.push(fp);
|
||||
}
|
||||
}
|
||||
|
||||
response.progress(detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
const toolName = event.toolName as string;
|
||||
const isError = event.isError as boolean;
|
||||
if (isError) {
|
||||
response.markdown(`\n**Tool \`${toolName}\` failed**\n`);
|
||||
} else {
|
||||
response.markdown(`\n*Tool \`${toolName}\` completed*\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_start": {
|
||||
// Assistant message starting
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -91,17 +80,16 @@ export function registerChatParticipant(
|
|||
response.markdown(delta);
|
||||
}
|
||||
} else if (assistantEvent.type === "thinking_delta") {
|
||||
// Show thinking content in a collapsed section
|
||||
// Thinking shown inline — prefix with italic so it's visually distinct
|
||||
const delta = assistantEvent.delta as string | undefined;
|
||||
if (delta) {
|
||||
response.markdown(delta);
|
||||
response.markdown(`*${delta}*`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_end": {
|
||||
// Capture token usage from message end events
|
||||
const usage = event.usage as { inputTokens?: number; outputTokens?: number } | undefined;
|
||||
if (usage) {
|
||||
if (usage.inputTokens) totalInputTokens += usage.inputTokens;
|
||||
|
|
@ -118,7 +106,6 @@ export function registerChatParticipant(
|
|||
|
||||
const subscription = client.onEvent(eventHandler);
|
||||
|
||||
// Handle cancellation
|
||||
token.onCancellationRequested(() => {
|
||||
client.abort().catch(() => {});
|
||||
});
|
||||
|
|
@ -132,29 +119,39 @@ export function registerChatParticipant(
|
|||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkDone = client.onEvent((evt) => {
|
||||
if (evt.type === "agent_end") {
|
||||
checkDone.dispose();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
token.onCancellationRequested(() => {
|
||||
checkDone.dispose();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Show token usage summary at the end
|
||||
// Show clickable file anchors for written files
|
||||
if (filesWritten.length > 0) {
|
||||
response.markdown("\n\n**Files changed:**");
|
||||
for (const fp of filesWritten) {
|
||||
const uri = resolveFileUri(fp);
|
||||
if (uri) {
|
||||
response.anchor(uri, fp);
|
||||
response.markdown(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Token usage summary
|
||||
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
||||
response.markdown(
|
||||
`\n\n---\n*Tokens: ${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out*\n`,
|
||||
`\n\n---\n*${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out tokens*`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
response.markdown(`\n**Error:** ${errorMessage}\n`);
|
||||
response.markdown(`\n**Error:** ${errorMessage}`);
|
||||
} finally {
|
||||
subscription.dispose();
|
||||
}
|
||||
|
|
@ -162,5 +159,125 @@ export function registerChatParticipant(
|
|||
|
||||
participant.iconPath = new vscode.ThemeIcon("hubot");
|
||||
|
||||
// Follow-up suggestions after each response
|
||||
participant.followupProvider = {
|
||||
provideFollowups: (_result, _context, _token) => {
|
||||
return [
|
||||
{
|
||||
prompt: "/gsd status",
|
||||
label: "$(info) Check status",
|
||||
title: "Check project status",
|
||||
},
|
||||
{
|
||||
prompt: "/gsd auto",
|
||||
label: "$(rocket) Run auto mode",
|
||||
title: "Run autonomous mode",
|
||||
},
|
||||
{
|
||||
prompt: "/gsd capture",
|
||||
label: "$(note) Capture a thought",
|
||||
title: "Capture a thought mid-session",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
return participant;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a file context block from any #file references in the chat request.
|
||||
*/
|
||||
async function buildFileContext(request: vscode.ChatRequest): Promise<string | null> {
|
||||
if (!request.references || request.references.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const ref of request.references) {
|
||||
if (ref.value instanceof vscode.Uri) {
|
||||
try {
|
||||
const bytes = await vscode.workspace.fs.readFile(ref.value);
|
||||
const content = Buffer.from(bytes).toString("utf-8");
|
||||
const relativePath = vscode.workspace.asRelativePath(ref.value);
|
||||
parts.push(`File: ${relativePath}\n\`\`\`\n${content}\n\`\`\``);
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
} else if (ref.value instanceof vscode.Location) {
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(ref.value.uri);
|
||||
const text = doc.getText(ref.value.range);
|
||||
const relativePath = vscode.workspace.asRelativePath(ref.value.uri);
|
||||
const { start, end } = ref.value.range;
|
||||
parts.push(`File: ${relativePath} (lines ${start.line + 1}–${end.line + 1})\n\`\`\`\n${text}\n\`\`\``);
|
||||
} catch {
|
||||
// Skip unreadable ranges
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join("\n\n") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a human-readable progress label for a tool call.
|
||||
*/
|
||||
function describeToolCall(toolName: string, input?: Record<string, unknown>): string {
|
||||
if (!input) {
|
||||
return `Running: ${toolName}`;
|
||||
}
|
||||
switch (toolName) {
|
||||
case "Read":
|
||||
return `Reading: ${shortenPath(String(input.file_path ?? ""))}`;
|
||||
case "Write":
|
||||
return `Writing: ${shortenPath(String(input.file_path ?? ""))}`;
|
||||
case "Edit":
|
||||
return `Editing: ${shortenPath(String(input.file_path ?? ""))}`;
|
||||
case "Bash": {
|
||||
const cmd = String(input.command ?? "");
|
||||
return `$ ${cmd.length > 80 ? cmd.slice(0, 77) + "…" : cmd}`;
|
||||
}
|
||||
case "Glob":
|
||||
return `Searching: ${input.pattern ?? ""}`;
|
||||
case "Grep":
|
||||
return `Grep: ${input.pattern ?? ""}`;
|
||||
case "WebSearch":
|
||||
return `Searching web: ${String(input.query ?? "").slice(0, 60)}`;
|
||||
case "WebFetch":
|
||||
return `Fetching: ${String(input.url ?? "").slice(0, 60)}`;
|
||||
default:
|
||||
return `Running: ${toolName}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten an absolute path to just the last 2–3 segments for display.
|
||||
*/
|
||||
function shortenPath(fp: string): string {
|
||||
const parts = fp.replace(/\\/g, "/").split("/");
|
||||
return parts.slice(-3).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve a file path string to a VS Code URI.
|
||||
*/
|
||||
function resolveFileUri(fp: string): vscode.Uri | null {
|
||||
try {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Absolute path
|
||||
if (fp.startsWith("/") || /^[A-Za-z]:[\\/]/.test(fp)) {
|
||||
return vscode.Uri.file(fp);
|
||||
}
|
||||
// Relative path — resolve against first workspace folder
|
||||
return vscode.Uri.joinPath(workspaceFolders[0].uri, fp);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
context.subscriptions.push(client);
|
||||
|
||||
// Log stderr to an output channel
|
||||
const outputChannel = vscode.window.createOutputChannel("GSD Agent");
|
||||
const outputChannel = vscode.window.createOutputChannel("GSD-2 Agent");
|
||||
context.subscriptions.push(outputChannel);
|
||||
|
||||
client.onError((msg) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue