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"] +}