From 48feced87d777313c955a1867eb2d153e9bacab3 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 14:07:21 -0500 Subject: [PATCH 01/23] feat: add VS Code extension scaffold and MCP server compiled module - Add vscode-extension/ with full MVP scaffold: - GsdClient: spawns gsd --mode rpc, JSON line communication - @gsd Chat participant: forward messages to agent, stream responses - Sidebar panel: connection status, model info, start/stop controls - Command palette: gsd.start, gsd.stop, gsd.newSession, gsd.sendMessage - Extension config: gsd.binaryPath setting - Add compiled MCP server module at src/mcp-server.ts for tsc output - Add MCP server tests verifying module import and instantiation --- src/mcp-server.ts | 39 ++- src/resources/extensions/gsd/mcp-server.ts | 45 +++- src/tests/mcp-server.test.ts | 47 ++++ vscode-extension/package.json | 85 ++++++ vscode-extension/src/chat-participant.ts | 118 +++++++++ vscode-extension/src/extension.ts | 115 +++++++++ vscode-extension/src/gsd-client.ts | 287 +++++++++++++++++++++ vscode-extension/src/sidebar.ts | 207 +++++++++++++++ vscode-extension/tsconfig.json | 19 ++ 9 files changed, 945 insertions(+), 17 deletions(-) create mode 100644 src/tests/mcp-server.test.ts create mode 100644 vscode-extension/package.json create mode 100644 vscode-extension/src/chat-participant.ts create mode 100644 vscode-extension/src/extension.ts create mode 100644 vscode-extension/src/gsd-client.ts create mode 100644 vscode-extension/src/sidebar.ts create mode 100644 vscode-extension/tsconfig.json diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 7d814b451..d3ea233fe 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,4 +1,8 @@ -interface McpTool { +/** + * Minimal tool interface matching GSD's AgentTool shape. + * Avoids a direct dependency on @gsd/pi-agent-core from this compiled module. + */ +export interface McpToolDef { name: string description: string parameters: Record @@ -17,8 +21,22 @@ interface McpTool { // specifiers dynamically so tsc treats them as `any`. const MCP_PKG = '@modelcontextprotocol/sdk' +/** + * Starts a native MCP (Model Context Protocol) server over stdin/stdout. + * + * This enables GSD's tools (read, write, edit, bash, grep, glob, ls, etc.) + * to be used by external AI clients such as Claude Desktop, VS Code Copilot, + * and any MCP-compatible host. + * + * The server registers all tools from the agent session's tool registry and + * maps MCP tools/list and tools/call requests to GSD tool definitions and + * execution, respectively. + * + * All MCP SDK imports are dynamic to avoid subpath export resolution issues + * with TypeScript's NodeNext module resolution. + */ export async function startMcpServer(options: { - tools: McpTool[] + tools: McpToolDef[] version?: string }): Promise { const { tools, version = '0.0.0' } = options @@ -31,7 +49,8 @@ export async function startMcpServer(options: { const StdioServerTransport = stdioMod.StdioServerTransport const { ListToolsRequestSchema, CallToolRequestSchema } = typesMod - const toolMap = new Map() + // Build a lookup map for fast tool resolution on calls + const toolMap = new Map() for (const tool of tools) { toolMap.set(tool.name, tool) } @@ -41,14 +60,16 @@ export async function startMcpServer(options: { { capabilities: { tools: {} } }, ) + // tools/list — return every registered GSD tool with its JSON Schema parameters server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: tools.map((t: McpTool) => ({ + tools: tools.map((t: McpToolDef) => ({ name: t.name, description: t.description, inputSchema: t.parameters, })), })) + // tools/call — execute the requested tool and return content blocks server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const { name, arguments: args } = request.params const tool = toolMap.get(name) @@ -60,7 +81,14 @@ export async function startMcpServer(options: { } try { - const result = await tool.execute(`mcp-${Date.now()}`, args ?? {}, undefined, undefined) + const result = await tool.execute( + `mcp-${Date.now()}`, + args ?? {}, + undefined, // no AbortSignal + undefined, // no onUpdate callback + ) + + // Convert AgentToolResult content blocks to MCP content format const content = result.content.map((block: any) => { if (block.type === 'text') return { type: 'text' as const, text: block.text ?? '' } if (block.type === 'image') return { type: 'image' as const, data: block.data ?? '', mimeType: block.mimeType ?? 'image/png' } @@ -73,6 +101,7 @@ export async function startMcpServer(options: { } }) + // Connect to stdin/stdout transport const transport = new StdioServerTransport() await server.connect(transport) process.stderr.write(`[gsd] MCP server started (v${version})\n`) diff --git a/src/resources/extensions/gsd/mcp-server.ts b/src/resources/extensions/gsd/mcp-server.ts index 624ef2054..1f62ce7dc 100644 --- a/src/resources/extensions/gsd/mcp-server.ts +++ b/src/resources/extensions/gsd/mcp-server.ts @@ -1,15 +1,24 @@ -// @ts-ignore — @modelcontextprotocol/sdk types may not be in extensions tsconfig -import { Server } from '@modelcontextprotocol/sdk/server' -// @ts-ignore -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio' -// @ts-ignore -import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types' +/** + * MCP (Model Context Protocol) server for the GSD extension. + * + * This module provides the same MCP server functionality as src/mcp-server.ts + * but can be loaded via jiti in the extension runtime context. It enables + * GSD's tools to be used by external AI clients (Claude Desktop, VS Code + * Copilot, etc.) via the MCP standard protocol over stdin/stdout. + */ interface McpTool { name: string description: string parameters: Record - execute(toolCallId: string, params: Record, signal?: AbortSignal, onUpdate?: unknown): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> }> + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + onUpdate?: unknown, + ): Promise<{ + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> + }> } export async function startMcpServer(options: { @@ -18,6 +27,16 @@ export async function startMcpServer(options: { }): Promise { const { tools, version = '0.0.0' } = options + // Dynamic imports — MCP SDK subpath exports use a "./*" wildcard pattern + // that cannot be statically resolved by all TypeScript configurations. + // @ts-ignore + const { Server } = await import('@modelcontextprotocol/sdk/server') + // @ts-ignore + const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js') + // @ts-ignore + const sdkTypes = await import('@modelcontextprotocol/sdk/types') + const { ListToolsRequestSchema, CallToolRequestSchema } = sdkTypes + const toolMap = new Map() for (const tool of tools) { toolMap.set(tool.name, tool) @@ -28,9 +47,10 @@ export async function startMcpServer(options: { { capabilities: { tools: {} } }, ) + // tools/list — return every registered GSD tool with its JSON Schema parameters server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: tools.map((t) => ({ + tools: tools.map((t: McpTool) => ({ name: t.name, description: t.description, inputSchema: t.parameters, @@ -38,6 +58,7 @@ export async function startMcpServer(options: { } }) + // tools/call — execute the requested tool and return content blocks server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const { name, arguments: args } = request.params const tool = toolMap.get(name) @@ -56,15 +77,15 @@ export async function startMcpServer(options: { undefined, ) - const content = result.content.map((block) => { + const content = result.content.map((block: any) => { if (block.type === 'text') { - return { type: 'text' as const, text: block.text } + return { type: 'text' as const, text: block.text ?? '' } } if (block.type === 'image') { return { type: 'image' as const, - data: block.data, - mimeType: block.mimeType, + data: block.data ?? '', + mimeType: block.mimeType ?? 'image/png', } } return { type: 'text' as const, text: JSON.stringify(block) } diff --git a/src/tests/mcp-server.test.ts b/src/tests/mcp-server.test.ts new file mode 100644 index 000000000..9e4f5cb8a --- /dev/null +++ b/src/tests/mcp-server.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..') + +test('mcp-server module imports without errors', async () => { + // Import from the compiled dist output to avoid subpath resolution issues + // that occur when the resolve-ts test hook rewrites .js -> .ts paths. + const distPath = join(projectRoot, 'dist', 'mcp-server.js') + const mod = await import(distPath) + assert.ok(mod, 'module should be importable') + assert.strictEqual(typeof mod.startMcpServer, 'function', 'startMcpServer should be a function') +}) + +test('startMcpServer accepts the correct argument shape', async () => { + const distPath = join(projectRoot, 'dist', 'mcp-server.js') + const { startMcpServer } = await import(distPath) + + assert.strictEqual(typeof startMcpServer, 'function') + assert.strictEqual(startMcpServer.length, 1, 'startMcpServer should accept one argument') +}) + +test('startMcpServer can be called with mock tools', async () => { + const distPath = join(projectRoot, 'dist', 'mcp-server.js') + const { startMcpServer } = await import(distPath) + + // Create a mock tool matching the McpToolDef interface + const mockTool = { + name: 'test_tool', + description: 'A test tool', + parameters: { type: 'object', properties: {} }, + execute: async () => ({ + content: [{ type: 'text', text: 'hello' }], + }), + } + + // Verify the function can be called with the correct signature + // without throwing during argument validation. It will attempt to + // connect to stdin/stdout as an MCP transport, which won't work in + // a test environment, but the Server instance is created successfully. + assert.doesNotThrow(() => { + void startMcpServer({ tools: [mockTool], version: '0.0.0-test' }) + .catch(() => { /* expected: no MCP client on stdin */ }) + }) +}) diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 000000000..b492e9968 --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,85 @@ +{ + "name": "gsd-vscode", + "displayName": "GSD - Get Shit Done", + "description": "VS Code integration for the GSD coding agent", + "publisher": "gsd-build", + "version": "0.1.0", + "license": "MIT", + "engines": { + "vscode": "^1.95.0" + }, + "categories": [ + "AI", + "Chat" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "dist/extension.js", + "contributes": { + "commands": [ + { + "command": "gsd.start", + "title": "GSD: Start Agent" + }, + { + "command": "gsd.stop", + "title": "GSD: Stop Agent" + }, + { + "command": "gsd.newSession", + "title": "GSD: New Session" + }, + { + "command": "gsd.sendMessage", + "title": "GSD: Send Message" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "gsd", + "title": "GSD", + "icon": "$(hubot)" + } + ] + }, + "views": { + "gsd": [ + { + "type": "webview", + "id": "gsd-sidebar", + "name": "GSD Agent" + } + ] + }, + "chatParticipants": [ + { + "id": "gsd.agent", + "name": "gsd", + "fullName": "GSD Agent", + "description": "Get Shit Done coding agent", + "isSticky": true + } + ], + "configuration": { + "title": "GSD", + "properties": { + "gsd.binaryPath": { + "type": "string", + "default": "gsd", + "description": "Path to the GSD binary" + } + } + } + }, + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "package": "vsce package" + }, + "devDependencies": { + "@types/vscode": "^1.95.0", + "typescript": "^5.7.0" + } +} diff --git a/vscode-extension/src/chat-participant.ts b/vscode-extension/src/chat-participant.ts new file mode 100644 index 000000000..7631963a1 --- /dev/null +++ b/vscode-extension/src/chat-participant.ts @@ -0,0 +1,118 @@ +import * as vscode from "vscode"; +import type { AgentEvent, GsdClient } from "./gsd-client.js"; + +/** + * Registers the @gsd chat participant that forwards messages to the + * GSD RPC client and streams tool execution events back to the chat. + */ +export function registerChatParticipant( + context: vscode.ExtensionContext, + client: GsdClient, +): vscode.Disposable { + const participant = vscode.chat.createChatParticipant("gsd.agent", async ( + request: vscode.ChatRequest, + _chatContext: vscode.ChatContext, + response: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) => { + if (!client.isConnected) { + response.markdown("GSD agent is not running. Use the **GSD: Start Agent** command first."); + return; + } + + const message = request.prompt; + if (!message.trim()) { + response.markdown("Please provide a message."); + return; + } + + // Track streaming events while the prompt executes + let agentDone = false; + + const eventHandler = (event: AgentEvent) => { + switch (event.type) { + case "agent_start": + response.progress("GSD is working..."); + break; + + case "tool_execution_start": + response.progress(`Running tool: ${event.toolName}`); + 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": { + const msg = event.message as Record; + if (msg && msg.role === "assistant") { + // Assistant message starting, will be followed by updates + } + break; + } + + case "message_update": { + const assistantEvent = event.assistantMessageEvent as Record | undefined; + if (assistantEvent?.type === "text_delta") { + const delta = assistantEvent.delta as string | undefined; + if (delta) { + response.markdown(delta); + } + } + break; + } + + case "agent_end": + agentDone = true; + break; + } + }; + + const subscription = client.onEvent(eventHandler); + + // Handle cancellation + token.onCancellationRequested(() => { + client.abort().catch(() => {}); + }); + + try { + await client.sendPrompt(message); + + // Wait for agent_end or cancellation + await new Promise((resolve) => { + if (agentDone) { + resolve(); + return; + } + + const checkDone = client.onEvent((evt) => { + if (evt.type === "agent_end") { + checkDone.dispose(); + resolve(); + } + }); + + token.onCancellationRequested(() => { + checkDone.dispose(); + resolve(); + }); + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + response.markdown(`\n**Error:** ${errorMessage}\n`); + } finally { + subscription.dispose(); + } + }); + + participant.iconPath = new vscode.ThemeIcon("hubot"); + + return participant; +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 000000000..f3024b5e4 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,115 @@ +import * as vscode from "vscode"; +import { GsdClient } from "./gsd-client.js"; +import { registerChatParticipant } from "./chat-participant.js"; +import { GsdSidebarProvider } from "./sidebar.js"; + +let client: GsdClient | undefined; +let sidebarProvider: GsdSidebarProvider | undefined; + +export function activate(context: vscode.ExtensionContext): void { + const config = vscode.workspace.getConfiguration("gsd"); + const binaryPath = config.get("binaryPath", "gsd"); + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + + client = new GsdClient(binaryPath, cwd); + context.subscriptions.push(client); + + // Log stderr to an output channel + const outputChannel = vscode.window.createOutputChannel("GSD Agent"); + context.subscriptions.push(outputChannel); + + client.onError((msg) => { + outputChannel.appendLine(`[stderr] ${msg}`); + }); + + client.onConnectionChange((connected) => { + if (connected) { + vscode.window.setStatusBarMessage("$(hubot) GSD connected", 3000); + } else { + vscode.window.setStatusBarMessage("$(hubot) GSD disconnected", 3000); + } + }); + + // -- Sidebar ----------------------------------------------------------- + + sidebarProvider = new GsdSidebarProvider(context.extensionUri, client); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + GsdSidebarProvider.viewId, + sidebarProvider, + ), + ); + + // -- Chat participant --------------------------------------------------- + + context.subscriptions.push(registerChatParticipant(context, client)); + + // -- Commands ----------------------------------------------------------- + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.start", async () => { + try { + await client!.start(); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage("GSD agent started."); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to start GSD: ${msg}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.stop", async () => { + await client!.stop(); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage("GSD agent stopped."); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.newSession", async () => { + if (!client!.isConnected) { + vscode.window.showWarningMessage("GSD agent is not running."); + return; + } + try { + await client!.newSession(); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage("New GSD session started."); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to start new session: ${msg}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.sendMessage", async () => { + if (!client!.isConnected) { + vscode.window.showWarningMessage("GSD agent is not running."); + return; + } + const message = await vscode.window.showInputBox({ + prompt: "Enter message for GSD", + placeHolder: "What should I do?", + }); + if (!message) { + return; + } + try { + await client!.sendPrompt(message); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to send message: ${msg}`); + } + }), + ); +} + +export function deactivate(): void { + client?.dispose(); + sidebarProvider?.dispose(); + client = undefined; + sidebarProvider = undefined; +} diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts new file mode 100644 index 000000000..247bdb760 --- /dev/null +++ b/vscode-extension/src/gsd-client.ts @@ -0,0 +1,287 @@ +import { ChildProcess, spawn } from "node:child_process"; +import * as vscode from "vscode"; + +/** + * Mirrors the RPC command/response protocol from the GSD agent. + * These types are intentionally kept minimal and self-contained so the + * extension has no dependency on the agent packages at runtime. + */ + +export interface RpcSessionState { + model?: { provider: string; id: string; contextWindow?: number }; + thinkingLevel: string; + isStreaming: boolean; + isCompacting: boolean; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; + sessionFile?: string; + sessionId: string; + sessionName?: string; + autoCompactionEnabled: boolean; + messageCount: number; + pendingMessageCount: number; +} + +export interface ModelInfo { + provider: string; + id: string; + contextWindow?: number; + reasoning?: boolean; +} + +export interface RpcResponse { + id?: string; + type: "response"; + command: string; + success: boolean; + data?: unknown; + error?: string; +} + +export interface AgentEvent { + type: string; + [key: string]: unknown; +} + +type PendingRequest = { + resolve: (response: RpcResponse) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +/** + * Client that spawns `gsd --mode rpc` and communicates via JSON lines + * over stdin/stdout. Emits VS Code events for streaming responses. + */ +export class GsdClient implements vscode.Disposable { + private process: ChildProcess | null = null; + private pendingRequests = new Map(); + private requestId = 0; + private buffer = ""; + private restartCount = 0; + + private readonly _onEvent = new vscode.EventEmitter(); + readonly onEvent = this._onEvent.event; + + private readonly _onConnectionChange = new vscode.EventEmitter(); + readonly onConnectionChange = this._onConnectionChange.event; + + private readonly _onError = new vscode.EventEmitter(); + readonly onError = this._onError.event; + + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly binaryPath: string, + private readonly cwd: string, + ) { + this.disposables.push(this._onEvent, this._onConnectionChange, this._onError); + } + + get isConnected(): boolean { + return this.process !== null && this.process.exitCode === null; + } + + /** + * Spawn the GSD agent in RPC mode. + */ + async start(): Promise { + if (this.process) { + return; + } + + this.process = spawn(this.binaryPath, ["--mode", "rpc", "--no-session"], { + cwd: this.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + }); + + this.buffer = ""; + + this.process.stdout?.on("data", (chunk: Buffer) => { + this.buffer += chunk.toString("utf8"); + this.drainBuffer(); + }); + + this.process.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8").trim(); + if (text) { + this._onError.fire(text); + } + }); + + this.process.on("exit", (code, signal) => { + this.process = null; + this.rejectAllPending(`GSD process exited (code=${code}, signal=${signal})`); + this._onConnectionChange.fire(false); + + if (this.restartCount < 3 && code !== 0 && signal !== "SIGTERM") { + this.restartCount++; + setTimeout(() => this.start(), 1000 * this.restartCount); + } + }); + + this._onConnectionChange.fire(true); + this.restartCount = 0; + } + + /** + * Stop the GSD agent process. + */ + async stop(): Promise { + if (!this.process) { + return; + } + + const proc = this.process; + this.process = null; + proc.kill("SIGTERM"); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + proc.kill("SIGKILL"); + resolve(); + }, 2000); + proc.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + this.rejectAllPending("Client stopped"); + this._onConnectionChange.fire(false); + } + + /** + * Send a prompt message to the agent. + * Returns once the command is acknowledged; streaming events follow via onEvent. + */ + async sendPrompt(message: string): Promise { + const response = await this.send({ type: "prompt", message }); + this.assertSuccess(response); + } + + /** + * Abort current operation. + */ + async abort(): Promise { + const response = await this.send({ type: "abort" }); + this.assertSuccess(response); + } + + /** + * Get current session state. + */ + async getState(): Promise { + const response = await this.send({ type: "get_state" }); + this.assertSuccess(response); + return response.data as RpcSessionState; + } + + /** + * Set the active model. + */ + async setModel(provider: string, modelId: string): Promise { + const response = await this.send({ type: "set_model", provider, modelId }); + this.assertSuccess(response); + } + + /** + * Get available models. + */ + async getAvailableModels(): Promise { + const response = await this.send({ type: "get_available_models" }); + this.assertSuccess(response); + return (response.data as { models: ModelInfo[] }).models; + } + + /** + * Start a new session. + */ + async newSession(): Promise { + const response = await this.send({ type: "new_session" }); + this.assertSuccess(response); + } + + dispose(): void { + this.stop(); + for (const d of this.disposables) { + d.dispose(); + } + } + + // -- Private helpers ------------------------------------------------------ + + private drainBuffer(): void { + while (true) { + const newlineIdx = this.buffer.indexOf("\n"); + if (newlineIdx === -1) { + break; + } + let line = this.buffer.slice(0, newlineIdx); + this.buffer = this.buffer.slice(newlineIdx + 1); + + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + if (!line) { + continue; + } + this.handleLine(line); + } + } + + private handleLine(line: string): void { + let data: Record; + try { + data = JSON.parse(line); + } catch { + return; // ignore non-JSON lines + } + + // Response to a pending request + if (data.type === "response" && typeof data.id === "string" && this.pendingRequests.has(data.id)) { + const pending = this.pendingRequests.get(data.id)!; + this.pendingRequests.delete(data.id); + clearTimeout(pending.timer); + pending.resolve(data as unknown as RpcResponse); + return; + } + + // Streaming event + this._onEvent.fire(data as AgentEvent); + } + + private send(command: Record): Promise { + if (!this.process?.stdin) { + return Promise.reject(new Error("GSD client not started")); + } + + const id = `req_${++this.requestId}`; + const fullCommand = { ...command, id }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Timeout waiting for response to ${command.type}`)); + }, 30_000); + + this.pendingRequests.set(id, { resolve, reject, timer }); + this.process!.stdin!.write(JSON.stringify(fullCommand) + "\n"); + }); + } + + private assertSuccess(response: RpcResponse): void { + if (!response.success) { + throw new Error(response.error ?? "Unknown RPC error"); + } + } + + private rejectAllPending(reason: string): void { + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pendingRequests.clear(); + } +} diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts new file mode 100644 index 000000000..708b0afcd --- /dev/null +++ b/vscode-extension/src/sidebar.ts @@ -0,0 +1,207 @@ +import * as vscode from "vscode"; +import type { GsdClient } from "./gsd-client.js"; + +/** + * WebviewViewProvider that renders a simple sidebar panel showing + * connection status, current model, session info, and start/stop controls. + */ +export class GsdSidebarProvider implements vscode.WebviewViewProvider { + public static readonly viewId = "gsd-sidebar"; + + private view?: vscode.WebviewView; + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly client: GsdClient, + ) { + this.disposables.push( + client.onConnectionChange(() => this.refresh()), + ); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + }; + + webviewView.webview.onDidReceiveMessage(async (msg: { command: string }) => { + switch (msg.command) { + case "start": + await vscode.commands.executeCommand("gsd.start"); + break; + case "stop": + await vscode.commands.executeCommand("gsd.stop"); + break; + case "newSession": + await vscode.commands.executeCommand("gsd.newSession"); + break; + } + }); + + this.refresh(); + } + + async refresh(): Promise { + if (!this.view) { + return; + } + + let modelName = "N/A"; + let sessionId = "N/A"; + let sessionName = ""; + let messageCount = 0; + + if (this.client.isConnected) { + try { + const state = await this.client.getState(); + modelName = state.model + ? `${state.model.provider}/${state.model.id}` + : "Not set"; + sessionId = state.sessionId; + sessionName = state.sessionName ?? ""; + messageCount = state.messageCount; + } catch { + // State fetch failed, show defaults + } + } + + const connected = this.client.isConnected; + + this.view.webview.html = this.getHtml({ + connected, + modelName, + sessionId, + sessionName, + messageCount, + }); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } + + private getHtml(info: { + connected: boolean; + modelName: string; + sessionId: string; + sessionName: string; + messageCount: number; + }): string { + const statusColor = info.connected ? "#4ec9b0" : "#f44747"; + const statusText = info.connected ? "Connected" : "Disconnected"; + + return /* html */ ` + + + + + + + +
+
+ ${statusText} +
+ + + + + +
Model${escapeHtml(info.modelName)}
Session${escapeHtml(info.sessionName || info.sessionId)}
Messages${info.messageCount}
+ +
+ ${info.connected + ? ` + ` + : `` + } +
+ + + +`; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 000000000..1a56af858 --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From d5e664c580038b07982273b20a8860e899e89f47 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 14:17:31 -0500 Subject: [PATCH 02/23] feat: fully flesh out VS Code extension with all RPC features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GsdClient — expose all 25 RPC commands: - Prompting: steer, followUp - Thinking: setThinkingLevel, cycleThinkingLevel - Compaction: compact, setAutoCompaction - Retry: setAutoRetry, abortRetry - Bash: runBash, abortBash - Session: getSessionStats, exportHtml, switchSession, setSessionName, getMessages, getLastAssistantText, getCommands - Model: cycleModel Extension — register 15 commands with full UI: - switchModel (QuickPick with context windows) - setThinking (QuickPick off/low/medium/high) - sessionStats (formatted token/cost display) - exportHtml (save dialog) - steer/runBash (input boxes) - listCommands (QuickPick, select to execute) - Keybindings: ctrl+shift+g chords for new session, cycle model, cycle thinking - Config: gsd.autoStart, gsd.autoCompaction Sidebar — full dashboard: - Thinking level badge and toggle - Token usage (input/output) and cost from session stats - Streaming spinner indicator - Model selector and quick action buttons (compact, export, abort) - Auto-compaction toggle - 10s periodic refresh for live stats Chat participant — enhanced event handling: - Tool-specific details (file paths, bash commands, grep patterns) - Thinking block display - Token usage summary at end of each response --- vscode-extension/package-lock.json | 41 ++++ vscode-extension/package.json | 71 ++++++ vscode-extension/src/chat-participant.ts | 72 +++++- vscode-extension/src/extension.ts | 280 +++++++++++++++++++++-- vscode-extension/src/gsd-client.ts | 224 +++++++++++++++++- vscode-extension/src/sidebar.ts | 267 +++++++++++++++++++-- 6 files changed, 905 insertions(+), 50 deletions(-) create mode 100644 vscode-extension/package-lock.json diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json new file mode 100644 index 000000000..58cb6c7a8 --- /dev/null +++ b/vscode-extension/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "gsd-vscode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gsd-vscode", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/vscode": "^1.95.0", + "typescript": "^5.7.0" + }, + "engines": { + "vscode": "^1.95.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json index b492e9968..9214348ed 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -33,6 +33,67 @@ { "command": "gsd.sendMessage", "title": "GSD: Send Message" + }, + { + "command": "gsd.cycleModel", + "title": "GSD: Cycle Model" + }, + { + "command": "gsd.cycleThinking", + "title": "GSD: Cycle Thinking Level" + }, + { + "command": "gsd.compact", + "title": "GSD: Compact Context" + }, + { + "command": "gsd.abort", + "title": "GSD: Abort Current Operation" + }, + { + "command": "gsd.exportHtml", + "title": "GSD: Export Conversation as HTML" + }, + { + "command": "gsd.sessionStats", + "title": "GSD: Show Session Stats" + }, + { + "command": "gsd.runBash", + "title": "GSD: Run Bash Command" + }, + { + "command": "gsd.switchModel", + "title": "GSD: Switch Model" + }, + { + "command": "gsd.setThinking", + "title": "GSD: Set Thinking Level" + }, + { + "command": "gsd.steer", + "title": "GSD: Steer Agent" + }, + { + "command": "gsd.listCommands", + "title": "GSD: List Available Commands" + } + ], + "keybindings": [ + { + "command": "gsd.newSession", + "key": "ctrl+shift+g ctrl+shift+n", + "mac": "cmd+shift+g cmd+shift+n" + }, + { + "command": "gsd.cycleModel", + "key": "ctrl+shift+g ctrl+shift+m", + "mac": "cmd+shift+g cmd+shift+m" + }, + { + "command": "gsd.cycleThinking", + "key": "ctrl+shift+g ctrl+shift+t", + "mac": "cmd+shift+g cmd+shift+t" } ], "viewsContainers": { @@ -69,6 +130,16 @@ "type": "string", "default": "gsd", "description": "Path to the GSD binary" + }, + "gsd.autoStart": { + "type": "boolean", + "default": false, + "description": "Automatically start the GSD agent when the extension activates" + }, + "gsd.autoCompaction": { + "type": "boolean", + "default": true, + "description": "Enable automatic context compaction" } } } diff --git a/vscode-extension/src/chat-participant.ts b/vscode-extension/src/chat-participant.ts index 7631963a1..1d630fe2e 100644 --- a/vscode-extension/src/chat-participant.ts +++ b/vscode-extension/src/chat-participant.ts @@ -26,8 +26,13 @@ export function registerChatParticipant( return; } + // If the message starts with /, forward as a slash command prompt + const isSlashCommand = message.startsWith("/"); + // Track streaming events while the prompt executes let agentDone = false; + let totalInputTokens = 0; + let totalOutputTokens = 0; const eventHandler = (event: AgentEvent) => { switch (event.type) { @@ -35,9 +40,33 @@ export function registerChatParticipant( response.progress("GSD is working..."); break; - case "tool_execution_start": - response.progress(`Running tool: ${event.toolName}`); + case "tool_execution_start": { + const toolName = event.toolName as string; + const toolInput = event.toolInput as Record | undefined; + + 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}`; + } + } + + response.progress(detail); break; + } case "tool_execution_end": { const toolName = event.toolName as string; @@ -51,20 +80,35 @@ export function registerChatParticipant( } case "message_start": { - const msg = event.message as Record; - if (msg && msg.role === "assistant") { - // Assistant message starting, will be followed by updates - } + // Assistant message starting break; } case "message_update": { const assistantEvent = event.assistantMessageEvent as Record | undefined; - if (assistantEvent?.type === "text_delta") { + if (!assistantEvent) break; + + if (assistantEvent.type === "text_delta") { const delta = assistantEvent.delta as string | undefined; if (delta) { response.markdown(delta); } + } else if (assistantEvent.type === "thinking_delta") { + // Show thinking content in a collapsed section + const delta = assistantEvent.delta as string | undefined; + if (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; + if (usage.outputTokens) totalOutputTokens += usage.outputTokens; } break; } @@ -83,7 +127,12 @@ export function registerChatParticipant( }); try { - await client.sendPrompt(message); + if (isSlashCommand) { + // Forward slash commands as regular prompts + await client.sendPrompt(message); + } else { + await client.sendPrompt(message); + } // Wait for agent_end or cancellation await new Promise((resolve) => { @@ -104,6 +153,13 @@ export function registerChatParticipant( resolve(); }); }); + + // Show token usage summary at the end + if (totalInputTokens > 0 || totalOutputTokens > 0) { + response.markdown( + `\n\n---\n*Tokens: ${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out*\n`, + ); + } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); response.markdown(`\n**Error:** ${errorMessage}\n`); diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index f3024b5e4..0f2d2de65 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,11 +1,24 @@ import * as vscode from "vscode"; -import { GsdClient } from "./gsd-client.js"; +import { GsdClient, ThinkingLevel } from "./gsd-client.js"; import { registerChatParticipant } from "./chat-participant.js"; import { GsdSidebarProvider } from "./sidebar.js"; let client: GsdClient | undefined; let sidebarProvider: GsdSidebarProvider | undefined; +function requireConnected(): boolean { + if (!client?.isConnected) { + vscode.window.showWarningMessage("GSD agent is not running."); + return false; + } + return true; +} + +function handleError(err: unknown, context: string): void { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`${context}: ${msg}`); +} + export function activate(context: vscode.ExtensionContext): void { const config = vscode.workspace.getConfiguration("gsd"); const binaryPath = config.get("binaryPath", "gsd"); @@ -46,19 +59,23 @@ export function activate(context: vscode.ExtensionContext): void { // -- Commands ----------------------------------------------------------- + // Start context.subscriptions.push( vscode.commands.registerCommand("gsd.start", async () => { try { await client!.start(); + // Apply auto-compaction setting + const autoCompaction = vscode.workspace.getConfiguration("gsd").get("autoCompaction", true); + await client!.setAutoCompaction(autoCompaction).catch(() => {}); sidebarProvider?.refresh(); vscode.window.showInformationMessage("GSD agent started."); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`Failed to start GSD: ${msg}`); + handleError(err, "Failed to start GSD"); } }), ); + // Stop context.subscriptions.push( vscode.commands.registerCommand("gsd.stop", async () => { await client!.stop(); @@ -67,44 +84,271 @@ export function activate(context: vscode.ExtensionContext): void { }), ); + // New Session context.subscriptions.push( vscode.commands.registerCommand("gsd.newSession", async () => { - if (!client!.isConnected) { - vscode.window.showWarningMessage("GSD agent is not running."); - return; - } + if (!requireConnected()) return; try { await client!.newSession(); sidebarProvider?.refresh(); vscode.window.showInformationMessage("New GSD session started."); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`Failed to start new session: ${msg}`); + handleError(err, "Failed to start new session"); } }), ); + // Send Message context.subscriptions.push( vscode.commands.registerCommand("gsd.sendMessage", async () => { - if (!client!.isConnected) { - vscode.window.showWarningMessage("GSD agent is not running."); - return; - } + if (!requireConnected()) return; const message = await vscode.window.showInputBox({ prompt: "Enter message for GSD", placeHolder: "What should I do?", }); - if (!message) { - return; - } + if (!message) return; try { await client!.sendPrompt(message); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`Failed to send message: ${msg}`); + handleError(err, "Failed to send message"); } }), ); + + // Abort + context.subscriptions.push( + vscode.commands.registerCommand("gsd.abort", async () => { + if (!requireConnected()) return; + try { + await client!.abort(); + vscode.window.showInformationMessage("Operation aborted."); + } catch (err) { + handleError(err, "Failed to abort"); + } + }), + ); + + // Cycle Model + context.subscriptions.push( + vscode.commands.registerCommand("gsd.cycleModel", async () => { + if (!requireConnected()) return; + try { + const result = await client!.cycleModel(); + if (result) { + vscode.window.showInformationMessage( + `Model: ${result.model.provider}/${result.model.id} (thinking: ${result.thinkingLevel})`, + ); + } else { + vscode.window.showInformationMessage("No other models available."); + } + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to cycle model"); + } + }), + ); + + // Switch Model (QuickPick) + context.subscriptions.push( + vscode.commands.registerCommand("gsd.switchModel", async () => { + if (!requireConnected()) return; + try { + const models = await client!.getAvailableModels(); + if (models.length === 0) { + vscode.window.showInformationMessage("No models available."); + return; + } + const items = models.map((m) => ({ + label: `${m.provider}/${m.id}`, + description: m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k context` : undefined, + provider: m.provider, + modelId: m.id, + })); + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Select a model", + }); + if (!selected) return; + await client!.setModel(selected.provider, selected.modelId); + vscode.window.showInformationMessage(`Model set to ${selected.label}`); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to switch model"); + } + }), + ); + + // Cycle Thinking Level + context.subscriptions.push( + vscode.commands.registerCommand("gsd.cycleThinking", async () => { + if (!requireConnected()) return; + try { + const result = await client!.cycleThinkingLevel(); + if (result) { + vscode.window.showInformationMessage(`Thinking level: ${result.level}`); + } else { + vscode.window.showInformationMessage("Cannot change thinking level for this model."); + } + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to cycle thinking level"); + } + }), + ); + + // Set Thinking Level (QuickPick) + context.subscriptions.push( + vscode.commands.registerCommand("gsd.setThinking", async () => { + if (!requireConnected()) return; + const levels: ThinkingLevel[] = ["off", "low", "medium", "high"]; + const selected = await vscode.window.showQuickPick(levels, { + placeHolder: "Select thinking level", + }); + if (!selected) return; + try { + await client!.setThinkingLevel(selected as ThinkingLevel); + vscode.window.showInformationMessage(`Thinking level set to ${selected}`); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to set thinking level"); + } + }), + ); + + // Compact Context + context.subscriptions.push( + vscode.commands.registerCommand("gsd.compact", async () => { + if (!requireConnected()) return; + try { + await client!.compact(); + vscode.window.showInformationMessage("Context compacted."); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to compact context"); + } + }), + ); + + // Export HTML + context.subscriptions.push( + vscode.commands.registerCommand("gsd.exportHtml", async () => { + if (!requireConnected()) return; + try { + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file("gsd-conversation.html"), + filters: { "HTML Files": ["html"] }, + }); + const outputPath = saveUri?.fsPath; + const result = await client!.exportHtml(outputPath); + vscode.window.showInformationMessage(`Conversation exported to ${result.path}`); + } catch (err) { + handleError(err, "Failed to export HTML"); + } + }), + ); + + // Session Stats + context.subscriptions.push( + vscode.commands.registerCommand("gsd.sessionStats", async () => { + if (!requireConnected()) return; + try { + const stats = await client!.getSessionStats(); + const lines: string[] = []; + if (stats.inputTokens !== undefined) lines.push(`Input tokens: ${stats.inputTokens.toLocaleString()}`); + if (stats.outputTokens !== undefined) lines.push(`Output tokens: ${stats.outputTokens.toLocaleString()}`); + if (stats.cacheReadTokens !== undefined) lines.push(`Cache read: ${stats.cacheReadTokens.toLocaleString()}`); + if (stats.cacheWriteTokens !== undefined) lines.push(`Cache write: ${stats.cacheWriteTokens.toLocaleString()}`); + if (stats.totalCost !== undefined) lines.push(`Cost: $${stats.totalCost.toFixed(4)}`); + if (stats.turnCount !== undefined) lines.push(`Turns: ${stats.turnCount}`); + if (stats.messageCount !== undefined) lines.push(`Messages: ${stats.messageCount}`); + if (stats.duration !== undefined) lines.push(`Duration: ${Math.round(stats.duration / 1000)}s`); + + vscode.window.showInformationMessage( + lines.length > 0 ? lines.join(" | ") : "No stats available.", + ); + } catch (err) { + handleError(err, "Failed to get session stats"); + } + }), + ); + + // Run Bash Command + context.subscriptions.push( + vscode.commands.registerCommand("gsd.runBash", async () => { + if (!requireConnected()) return; + const command = await vscode.window.showInputBox({ + prompt: "Enter bash command to execute", + placeHolder: "ls -la", + }); + if (!command) return; + try { + const result = await client!.runBash(command); + outputChannel.appendLine(`[bash] $ ${command}`); + if (result.stdout) outputChannel.appendLine(result.stdout); + if (result.stderr) outputChannel.appendLine(`[stderr] ${result.stderr}`); + outputChannel.appendLine(`[exit code: ${result.exitCode}]`); + outputChannel.show(true); + + if (result.exitCode === 0) { + vscode.window.showInformationMessage("Bash command completed successfully."); + } else { + vscode.window.showWarningMessage(`Bash command exited with code ${result.exitCode}`); + } + } catch (err) { + handleError(err, "Failed to run bash command"); + } + }), + ); + + // Steer Agent + context.subscriptions.push( + vscode.commands.registerCommand("gsd.steer", async () => { + if (!requireConnected()) return; + const message = await vscode.window.showInputBox({ + prompt: "Enter steering message (interrupts current operation)", + placeHolder: "Focus on the error handling instead", + }); + if (!message) return; + try { + await client!.steer(message); + } catch (err) { + handleError(err, "Failed to steer agent"); + } + }), + ); + + // List Available Commands + context.subscriptions.push( + vscode.commands.registerCommand("gsd.listCommands", async () => { + if (!requireConnected()) return; + try { + const commands = await client!.getCommands(); + if (commands.length === 0) { + vscode.window.showInformationMessage("No slash commands available."); + return; + } + const items = commands.map((cmd) => ({ + label: `/${cmd.name}`, + description: cmd.description ?? "", + detail: `Source: ${cmd.source}${cmd.location ? ` (${cmd.location})` : ""}`, + })); + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Available slash commands", + }); + if (selected) { + // Send the selected command as a prompt + await client!.sendPrompt(selected.label); + } + } catch (err) { + handleError(err, "Failed to list commands"); + } + }), + ); + + // -- Auto-start --------------------------------------------------------- + + if (config.get("autoStart", false)) { + vscode.commands.executeCommand("gsd.start"); + } } export function deactivate(): void { diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts index 247bdb760..19a4ddc53 100644 --- a/vscode-extension/src/gsd-client.ts +++ b/vscode-extension/src/gsd-client.ts @@ -7,9 +7,11 @@ import * as vscode from "vscode"; * extension has no dependency on the agent packages at runtime. */ +export type ThinkingLevel = "off" | "low" | "medium" | "high"; + export interface RpcSessionState { model?: { provider: string; id: string; contextWindow?: number }; - thinkingLevel: string; + thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; steeringMode: "all" | "one-at-a-time"; @@ -29,6 +31,31 @@ export interface ModelInfo { reasoning?: boolean; } +export interface SessionStats { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + totalCost?: number; + messageCount?: number; + turnCount?: number; + duration?: number; +} + +export interface BashResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +export interface SlashCommand { + name: string; + description?: string; + source: "extension" | "prompt" | "skill"; + location?: "user" | "project" | "path"; + path?: string; +} + export interface RpcResponse { id?: string; type: "response"; @@ -152,6 +179,10 @@ export class GsdClient implements vscode.Disposable { this._onConnectionChange.fire(false); } + // ========================================================================= + // Prompting + // ========================================================================= + /** * Send a prompt message to the agent. * Returns once the command is acknowledged; streaming events follow via onEvent. @@ -161,6 +192,22 @@ export class GsdClient implements vscode.Disposable { this.assertSuccess(response); } + /** + * Interrupt the agent with a steering message while it is streaming. + */ + async steer(message: string): Promise { + const response = await this.send({ type: "steer", message }); + this.assertSuccess(response); + } + + /** + * Send a follow-up message after the agent has completed. + */ + async followUp(message: string): Promise { + const response = await this.send({ type: "follow_up", message }); + this.assertSuccess(response); + } + /** * Abort current operation. */ @@ -169,6 +216,10 @@ export class GsdClient implements vscode.Disposable { this.assertSuccess(response); } + // ========================================================================= + // State + // ========================================================================= + /** * Get current session state. */ @@ -178,6 +229,10 @@ export class GsdClient implements vscode.Disposable { return response.data as RpcSessionState; } + // ========================================================================= + // Model + // ========================================================================= + /** * Set the active model. */ @@ -195,6 +250,106 @@ export class GsdClient implements vscode.Disposable { return (response.data as { models: ModelInfo[] }).models; } + /** + * Cycle through available models. + */ + async cycleModel(): Promise<{ model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null> { + const response = await this.send({ type: "cycle_model" }); + this.assertSuccess(response); + return response.data as { model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null; + } + + // ========================================================================= + // Thinking + // ========================================================================= + + /** + * Set the thinking level explicitly. + */ + async setThinkingLevel(level: ThinkingLevel): Promise { + const response = await this.send({ type: "set_thinking_level", level }); + this.assertSuccess(response); + } + + /** + * Cycle through thinking levels (off -> low -> medium -> high -> off). + */ + async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> { + const response = await this.send({ type: "cycle_thinking_level" }); + this.assertSuccess(response); + return response.data as { level: ThinkingLevel } | null; + } + + // ========================================================================= + // Compaction + // ========================================================================= + + /** + * Manually compact the conversation context. + */ + async compact(customInstructions?: string): Promise { + const cmd: Record = { type: "compact" }; + if (customInstructions) { + cmd.customInstructions = customInstructions; + } + const response = await this.send(cmd); + this.assertSuccess(response); + return response.data; + } + + /** + * Enable or disable automatic compaction. + */ + async setAutoCompaction(enabled: boolean): Promise { + const response = await this.send({ type: "set_auto_compaction", enabled }); + this.assertSuccess(response); + } + + // ========================================================================= + // Retry + // ========================================================================= + + /** + * Enable or disable automatic retry on failure. + */ + async setAutoRetry(enabled: boolean): Promise { + const response = await this.send({ type: "set_auto_retry", enabled }); + this.assertSuccess(response); + } + + /** + * Abort a pending retry. + */ + async abortRetry(): Promise { + const response = await this.send({ type: "abort_retry" }); + this.assertSuccess(response); + } + + // ========================================================================= + // Bash + // ========================================================================= + + /** + * Execute a bash command via the agent. + */ + async runBash(command: string): Promise { + const response = await this.send({ type: "bash", command }); + this.assertSuccess(response); + return response.data as BashResult; + } + + /** + * Abort a running bash command. + */ + async abortBash(): Promise { + const response = await this.send({ type: "abort_bash" }); + this.assertSuccess(response); + } + + // ========================================================================= + // Session + // ========================================================================= + /** * Start a new session. */ @@ -203,6 +358,71 @@ export class GsdClient implements vscode.Disposable { this.assertSuccess(response); } + /** + * Get session statistics (token counts, cost, etc.). + */ + async getSessionStats(): Promise { + const response = await this.send({ type: "get_session_stats" }); + this.assertSuccess(response); + return response.data as SessionStats; + } + + /** + * Export the conversation as HTML. + */ + async exportHtml(outputPath?: string): Promise<{ path: string }> { + const cmd: Record = { type: "export_html" }; + if (outputPath) { + cmd.outputPath = outputPath; + } + const response = await this.send(cmd); + this.assertSuccess(response); + return response.data as { path: string }; + } + + /** + * Switch to a different session file. + */ + async switchSession(sessionPath: string): Promise { + const response = await this.send({ type: "switch_session", sessionPath }); + this.assertSuccess(response); + } + + /** + * Set the display name for the current session. + */ + async setSessionName(name: string): Promise { + const response = await this.send({ type: "set_session_name", name }); + this.assertSuccess(response); + } + + /** + * Get all conversation messages. + */ + async getMessages(): Promise { + const response = await this.send({ type: "get_messages" }); + this.assertSuccess(response); + return (response.data as { messages: unknown[] }).messages; + } + + /** + * Get the text of the last assistant response. + */ + async getLastAssistantText(): Promise { + const response = await this.send({ type: "get_last_assistant_text" }); + this.assertSuccess(response); + return (response.data as { text: string | null }).text; + } + + /** + * List available slash commands. + */ + async getCommands(): Promise { + const response = await this.send({ type: "get_commands" }); + this.assertSuccess(response); + return (response.data as { commands: SlashCommand[] }).commands; + } + dispose(): void { this.stop(); for (const d of this.disposables) { @@ -278,7 +498,7 @@ export class GsdClient implements vscode.Disposable { } private rejectAllPending(reason: string): void { - for (const [id, pending] of this.pendingRequests) { + for (const [, pending] of this.pendingRequests) { clearTimeout(pending.timer); pending.reject(new Error(reason)); } diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts index 708b0afcd..684faaf32 100644 --- a/vscode-extension/src/sidebar.ts +++ b/vscode-extension/src/sidebar.ts @@ -1,15 +1,16 @@ import * as vscode from "vscode"; -import type { GsdClient } from "./gsd-client.js"; +import type { GsdClient, SessionStats, ThinkingLevel } from "./gsd-client.js"; /** - * WebviewViewProvider that renders a simple sidebar panel showing - * connection status, current model, session info, and start/stop controls. + * WebviewViewProvider that renders a sidebar panel showing connection status, + * model info, thinking level, token usage, cost, and quick action controls. */ export class GsdSidebarProvider implements vscode.WebviewViewProvider { public static readonly viewId = "gsd-sidebar"; private view?: vscode.WebviewView; private disposables: vscode.Disposable[] = []; + private refreshTimer: ReturnType | undefined; constructor( private readonly extensionUri: vscode.Uri, @@ -17,6 +18,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { ) { this.disposables.push( client.onConnectionChange(() => this.refresh()), + client.onEvent((evt) => { + // Refresh on streaming state changes + if (evt.type === "agent_start" || evt.type === "agent_end") { + this.refresh(); + } + }), ); } @@ -31,7 +38,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { enableScripts: true, }; - webviewView.webview.onDidReceiveMessage(async (msg: { command: string }) => { + webviewView.webview.onDidReceiveMessage(async (msg: { command: string; value?: string }) => { switch (msg.command) { case "start": await vscode.commands.executeCommand("gsd.start"); @@ -42,9 +49,52 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { case "newSession": await vscode.commands.executeCommand("gsd.newSession"); break; + case "cycleModel": + await vscode.commands.executeCommand("gsd.cycleModel"); + break; + case "cycleThinking": + await vscode.commands.executeCommand("gsd.cycleThinking"); + break; + case "switchModel": + await vscode.commands.executeCommand("gsd.switchModel"); + break; + case "setThinking": + await vscode.commands.executeCommand("gsd.setThinking"); + break; + case "compact": + await vscode.commands.executeCommand("gsd.compact"); + break; + case "abort": + await vscode.commands.executeCommand("gsd.abort"); + break; + case "exportHtml": + await vscode.commands.executeCommand("gsd.exportHtml"); + break; + case "sessionStats": + await vscode.commands.executeCommand("gsd.sessionStats"); + break; + case "listCommands": + await vscode.commands.executeCommand("gsd.listCommands"); + break; + case "toggleAutoCompaction": + if (this.client.isConnected) { + const state = await this.client.getState().catch(() => null); + if (state) { + await this.client.setAutoCompaction(!state.autoCompactionEnabled).catch(() => {}); + this.refresh(); + } + } + break; } }); + // Periodic refresh while connected (for token stats) + this.refreshTimer = setInterval(() => { + if (this.client.isConnected) { + this.refresh(); + } + }, 10_000); + this.refresh(); } @@ -57,6 +107,11 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { let sessionId = "N/A"; let sessionName = ""; let messageCount = 0; + let thinkingLevel: ThinkingLevel = "off"; + let isStreaming = false; + let isCompacting = false; + let autoCompaction = false; + let stats: SessionStats | null = null; if (this.client.isConnected) { try { @@ -67,9 +122,19 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { sessionId = state.sessionId; sessionName = state.sessionName ?? ""; messageCount = state.messageCount; + thinkingLevel = state.thinkingLevel as ThinkingLevel; + isStreaming = state.isStreaming; + isCompacting = state.isCompacting; + autoCompaction = state.autoCompactionEnabled; } catch { // State fetch failed, show defaults } + + try { + stats = await this.client.getSessionStats(); + } catch { + // Stats fetch failed + } } const connected = this.client.isConnected; @@ -80,10 +145,18 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { sessionId, sessionName, messageCount, + thinkingLevel, + isStreaming, + isCompacting, + autoCompaction, + stats, }); } dispose(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + } for (const d of this.disposables) { d.dispose(); } @@ -95,9 +168,36 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { sessionId: string; sessionName: string; messageCount: number; + thinkingLevel: ThinkingLevel; + isStreaming: boolean; + isCompacting: boolean; + autoCompaction: boolean; + stats: SessionStats | null; }): string { const statusColor = info.connected ? "#4ec9b0" : "#f44747"; - const statusText = info.connected ? "Connected" : "Disconnected"; + const statusText = info.connected + ? info.isStreaming + ? "Processing..." + : info.isCompacting + ? "Compacting..." + : "Connected" + : "Disconnected"; + + const inputTokens = info.stats?.inputTokens?.toLocaleString() ?? "-"; + const outputTokens = info.stats?.outputTokens?.toLocaleString() ?? "-"; + const cost = info.stats?.totalCost !== undefined ? `$${info.stats.totalCost.toFixed(4)}` : "-"; + + const thinkingBadge = info.thinkingLevel !== "off" + ? `${info.thinkingLevel}` + : `off`; + + const autoCompBadge = info.autoCompaction + ? `on` + : `off`; + + const streamingIndicator = info.isStreaming + ? `
Agent is working...
` + : ""; return /* html */ ` @@ -116,20 +216,53 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { display: flex; align-items: center; gap: 8px; - margin-bottom: 16px; + margin-bottom: 12px; } .status-dot { width: 10px; height: 10px; border-radius: 50%; background: ${statusColor}; + flex-shrink: 0; + } + .streaming-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + margin-bottom: 12px; + background: var(--vscode-editor-background); + border-radius: 4px; + border: 1px solid var(--vscode-focusBorder); + font-size: 12px; + } + .spinner { + width: 12px; + height: 12px; + border: 2px solid var(--vscode-foreground); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + .section { + margin-bottom: 14px; + } + .section-title { + font-size: 11px; + text-transform: uppercase; + opacity: 0.6; + margin-bottom: 6px; + letter-spacing: 0.5px; } .info-table { width: 100%; - margin-bottom: 16px; } .info-table td { - padding: 4px 0; + padding: 3px 0; + vertical-align: middle; } .info-table td:first-child { opacity: 0.7; @@ -139,10 +272,34 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { .info-table td:last-child { word-break: break-all; } + .badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + } + .badge.muted { + opacity: 0.5; + } + .badge.clickable { + cursor: pointer; + } + .badge.clickable:hover { + opacity: 0.8; + } .btn-group { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; + } + .btn-row { + display: flex; + gap: 6px; + } + .btn-row button { + flex: 1; } button { display: block; @@ -165,6 +322,19 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); } + .token-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 12px; + font-size: 12px; + } + .token-stats .label { + opacity: 0.7; + } + .token-stats .value { + text-align: right; + font-variant-numeric: tabular-nums; + } @@ -173,24 +343,77 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { ${statusText} - - - - -
Model${escapeHtml(info.modelName)}
Session${escapeHtml(info.sessionName || info.sessionId)}
Messages${info.messageCount}
+ ${streamingIndicator} -
- ${info.connected - ? ` - ` - : `` - } +
+
Session
+ + + + + + + + + + + + +
Model${escapeHtml(info.modelName)}
Session${escapeHtml(info.sessionName || info.sessionId)}
Messages${info.messageCount}
Thinking${thinkingBadge}
Auto-compact${autoCompBadge}
+ ${info.connected && info.stats ? ` +
+
Token Usage
+
+ Input + ${inputTokens} + Output + ${outputTokens} + Cost + ${cost} +
+
+ ` : ""} + +
+
Controls
+
+ ${info.connected + ? ` +
+ + +
+
+ + +
` + : `` + } +
+
+ + ${info.connected ? ` +
+
Actions
+
+
+ + +
+
+ + +
+
+
+ ` : ""} + From 6ed9cd5359e4c3aca427d51636a4c2994c37f9b5 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 16:53:34 -0500 Subject: [PATCH 03/23] fix: resolve CI failures in VS Code extension PR - Fix Windows MCP test failures: use pathToFileURL() instead of bare join() paths for dynamic imports, fixing ERR_UNSUPPORTED_ESM_URL_SCHEME on Windows where D:\ paths are not valid ESM import specifiers - Remove parallel orchestration code that was WIP from another feature branch and not part of the VS Code extension scope (commands.ts, preferences.ts, types.ts changes reverted to main) - Rebase cleanly onto main, resolving mcp-server.ts merge conflict by keeping main's dynamic import approach with PR's exported interface and JSDoc documentation --- src/tests/mcp-server.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/tests/mcp-server.test.ts b/src/tests/mcp-server.test.ts index 9e4f5cb8a..9581809dd 100644 --- a/src/tests/mcp-server.test.ts +++ b/src/tests/mcp-server.test.ts @@ -1,30 +1,37 @@ import test from 'node:test' import assert from 'node:assert/strict' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..') +/** + * Resolve dist path as a file:// URL for cross-platform dynamic import. + * On Windows, bare paths like `D:\...\mcp-server.js` fail with + * ERR_UNSUPPORTED_ESM_URL_SCHEME because Node's ESM loader requires + * file:// URLs for absolute paths. + */ +function distUrl(filename: string): string { + return pathToFileURL(join(projectRoot, 'dist', filename)).href +} + test('mcp-server module imports without errors', async () => { // Import from the compiled dist output to avoid subpath resolution issues // that occur when the resolve-ts test hook rewrites .js -> .ts paths. - const distPath = join(projectRoot, 'dist', 'mcp-server.js') - const mod = await import(distPath) + const mod = await import(distUrl('mcp-server.js')) assert.ok(mod, 'module should be importable') assert.strictEqual(typeof mod.startMcpServer, 'function', 'startMcpServer should be a function') }) test('startMcpServer accepts the correct argument shape', async () => { - const distPath = join(projectRoot, 'dist', 'mcp-server.js') - const { startMcpServer } = await import(distPath) + const { startMcpServer } = await import(distUrl('mcp-server.js')) assert.strictEqual(typeof startMcpServer, 'function') assert.strictEqual(startMcpServer.length, 1, 'startMcpServer should accept one argument') }) test('startMcpServer can be called with mock tools', async () => { - const distPath = join(projectRoot, 'dist', 'mcp-server.js') - const { startMcpServer } = await import(distPath) + const { startMcpServer } = await import(distUrl('mcp-server.js')) // Create a mock tool matching the McpToolDef interface const mockTool = { From add9e8cf3cb3feb61517e7469073d90511de760e Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 17:28:32 -0500 Subject: [PATCH 04/23] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20CSP=20nonce,=20dead=20branch,=20restart=20cooldown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Webview CSP nonce (security): Added Content-Security-Policy meta tag with nonce-based script-src to sidebar.ts. Replaced all inline onclick handlers with data-command attributes and a single delegated event listener, which CSP requires over inline handlers. 2. Dead branch in chat-participant.ts: Removed the isSlashCommand conditional that ran identical code for both paths — slash commands and regular messages both call sendPrompt() the same way. 3. Restart loop cooldown in gsd-client.ts: Added a 60-second sliding window that tracks crash timestamps. If the process crashes more than 3 times within 60 seconds, auto-restart is disabled and an error is surfaced to the user via the onError event emitter. --- vscode-extension/src/chat-participant.ts | 10 +----- vscode-extension/src/gsd-client.ts | 19 +++++++++-- vscode-extension/src/sidebar.ts | 43 ++++++++++++++++-------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/vscode-extension/src/chat-participant.ts b/vscode-extension/src/chat-participant.ts index 1d630fe2e..c4ba367df 100644 --- a/vscode-extension/src/chat-participant.ts +++ b/vscode-extension/src/chat-participant.ts @@ -26,9 +26,6 @@ export function registerChatParticipant( return; } - // If the message starts with /, forward as a slash command prompt - const isSlashCommand = message.startsWith("/"); - // Track streaming events while the prompt executes let agentDone = false; let totalInputTokens = 0; @@ -127,12 +124,7 @@ export function registerChatParticipant( }); try { - if (isSlashCommand) { - // Forward slash commands as regular prompts - await client.sendPrompt(message); - } else { - await client.sendPrompt(message); - } + await client.sendPrompt(message); // Wait for agent_end or cancellation await new Promise((resolve) => { diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts index 19a4ddc53..20db6d327 100644 --- a/vscode-extension/src/gsd-client.ts +++ b/vscode-extension/src/gsd-client.ts @@ -86,6 +86,7 @@ export class GsdClient implements vscode.Disposable { private requestId = 0; private buffer = ""; private restartCount = 0; + private restartTimestamps: number[] = []; private readonly _onEvent = new vscode.EventEmitter(); readonly onEvent = this._onEvent.event; @@ -142,9 +143,21 @@ export class GsdClient implements vscode.Disposable { this.rejectAllPending(`GSD process exited (code=${code}, signal=${signal})`); this._onConnectionChange.fire(false); - if (this.restartCount < 3 && code !== 0 && signal !== "SIGTERM") { - this.restartCount++; - setTimeout(() => this.start(), 1000 * this.restartCount); + if (code !== 0 && signal !== "SIGTERM") { + const now = Date.now(); + this.restartTimestamps.push(now); + // Keep only timestamps within the last 60 seconds + this.restartTimestamps = this.restartTimestamps.filter(t => now - t < 60_000); + + if (this.restartTimestamps.length > 3) { + // Too many crashes within 60s — stop retrying + this._onError.fire( + `GSD process crashed ${this.restartTimestamps.length} times within 60s. Not restarting. Use "GSD: Start Agent" to retry manually.`, + ); + } else if (this.restartCount < 3) { + this.restartCount++; + setTimeout(() => this.start(), 1000 * this.restartCount); + } } }); diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts index 684faaf32..961c56d0d 100644 --- a/vscode-extension/src/sidebar.ts +++ b/vscode-extension/src/sidebar.ts @@ -199,11 +199,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { ? `
Agent is working...
` : ""; + const nonce = getNonce(); + return /* html */ ` +