From d5e664c580038b07982273b20a8860e899e89f47 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 14:17:31 -0500 Subject: [PATCH] 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
+
+
+ + +
+
+ + +
+
+
+ ` : ""} +