Merge pull request #755 from jeremymcs/feat/vscode-marketplace

feat(vscode): marketplace-ready files for VS Code extension publishing
This commit is contained in:
TÂCHES 2026-03-16 18:43:31 -06:00 committed by GitHub
commit 889a2ee137
15 changed files with 4475 additions and 162 deletions

View file

@ -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) {

View file

@ -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: {

View file

@ -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;

View file

@ -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;

View file

@ -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
View file

@ -0,0 +1,3 @@
dist/
node_modules/
*.vsix

View file

@ -0,0 +1,9 @@
.vscode/**
.vscode-test/**
src/**
.gitignore
tsconfig.json
**/*.ts
!dist/**
node_modules/**
**/*.map

View 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
View 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.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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 23 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;
}
}

View file

@ -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) => {