diff --git a/vscode-extension/package.json b/vscode-extension/package.json index ee73b229c..be0a26007 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,7 +1,7 @@ { "name": "gsd-2", "displayName": "GSD-2", - "description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, and 15 commands", + "description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, conversation history, code lens, slash command completion, and 25 commands", "publisher": "FluxLabs", "version": "0.1.0", "icon": "logo.jpg", @@ -102,6 +102,43 @@ { "command": "gsd.listCommands", "title": "GSD: List Available Commands" + }, + { + "command": "gsd.toggleAutoRetry", + "title": "GSD: Toggle Auto-Retry" + }, + { + "command": "gsd.abortRetry", + "title": "GSD: Abort Retry" + }, + { + "command": "gsd.setSessionName", + "title": "GSD: Set Session Name" + }, + { + "command": "gsd.copyLastResponse", + "title": "GSD: Copy Last Response" + }, + { + "command": "gsd.switchSession", + "title": "GSD: Switch Session" + }, + { + "command": "gsd.refreshSessions", + "title": "GSD: Refresh Sessions", + "icon": "$(refresh)" + }, + { + "command": "gsd.clearFileDecorations", + "title": "GSD: Clear File Decorations" + }, + { + "command": "gsd.showHistory", + "title": "GSD: Show Conversation History" + }, + { + "command": "gsd.askAboutSymbol", + "title": "GSD: Ask About Symbol" } ], "keybindings": [ @@ -119,6 +156,21 @@ "command": "gsd.cycleThinking", "key": "ctrl+shift+g ctrl+shift+t", "mac": "cmd+shift+g cmd+shift+t" + }, + { + "command": "gsd.abort", + "key": "ctrl+shift+g ctrl+shift+a", + "mac": "cmd+shift+g cmd+shift+a" + }, + { + "command": "gsd.steer", + "key": "ctrl+shift+g ctrl+shift+i", + "mac": "cmd+shift+g cmd+shift+i" + }, + { + "command": "gsd.sendMessage", + "key": "ctrl+shift+g ctrl+shift+p", + "mac": "cmd+shift+g cmd+shift+p" } ], "viewsContainers": { @@ -136,6 +188,19 @@ "type": "webview", "id": "gsd-sidebar", "name": "GSD Agent" + }, + { + "id": "gsd-sessions", + "name": "Sessions" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "gsd.refreshSessions", + "when": "view == gsd-sessions", + "group": "navigation" } ] }, @@ -165,6 +230,11 @@ "type": "boolean", "default": true, "description": "Enable automatic context compaction" + }, + "gsd.codeLens": { + "type": "boolean", + "default": true, + "description": "Show 'Ask GSD' code lens above functions and classes" } } } diff --git a/vscode-extension/src/bash-terminal.ts b/vscode-extension/src/bash-terminal.ts new file mode 100644 index 000000000..7d1226615 --- /dev/null +++ b/vscode-extension/src/bash-terminal.ts @@ -0,0 +1,84 @@ +import * as vscode from "vscode"; +import type { AgentEvent, GsdClient } from "./gsd-client.js"; + +/** + * Routes the GSD agent's Bash tool output to a dedicated VS Code terminal panel. + * Shows streaming output from tool_execution_update events in real time. + */ +export class GsdBashTerminal implements vscode.Disposable { + private terminal: vscode.Terminal | undefined; + private writeEmitter: vscode.EventEmitter | undefined; + private disposables: vscode.Disposable[] = []; + + constructor(client: GsdClient) { + this.disposables.push( + client.onEvent((evt: AgentEvent) => this.handleEvent(evt)), + client.onConnectionChange((connected) => { + if (!connected) { + this.close(); + } + }), + ); + } + + private getOrCreateTerminal(): { terminal: vscode.Terminal; writeEmitter: vscode.EventEmitter } { + if (!this.terminal || this.terminal.exitStatus !== undefined) { + this.writeEmitter?.dispose(); + this.writeEmitter = new vscode.EventEmitter(); + const emitter = this.writeEmitter; + const pty: vscode.Pseudoterminal = { + onDidWrite: emitter.event, + open: () => {}, + close: () => { this.terminal = undefined; }, + }; + this.terminal = vscode.window.createTerminal({ name: "GSD Agent", pty }); + } + return { terminal: this.terminal, writeEmitter: this.writeEmitter! }; + } + + private handleEvent(evt: AgentEvent): void { + switch (evt.type) { + case "tool_execution_start": { + if (evt.toolName !== "Bash") { + break; + } + const cmd = (evt.toolInput as Record | undefined)?.command as string | undefined; + const { terminal, writeEmitter } = this.getOrCreateTerminal(); + terminal.show(true); // preserve editor focus + writeEmitter.fire(`\x1b[90m$ ${cmd ?? ""}\x1b[0m\r\n`); + break; + } + case "tool_execution_update": { + if (evt.toolName !== "Bash" || !this.writeEmitter) { + break; + } + const partial = evt.partialResult as string | undefined; + if (partial) { + this.writeEmitter.fire(partial.replace(/\n/g, "\r\n")); + } + break; + } + case "tool_execution_end": { + if (evt.toolName !== "Bash" || !this.writeEmitter) { + break; + } + this.writeEmitter.fire("\r\n"); + break; + } + } + } + + close(): void { + this.terminal?.dispose(); + this.terminal = undefined; + this.writeEmitter?.dispose(); + this.writeEmitter = undefined; + } + + dispose(): void { + this.close(); + for (const d of this.disposables) { + d.dispose(); + } + } +} diff --git a/vscode-extension/src/code-lens.ts b/vscode-extension/src/code-lens.ts new file mode 100644 index 000000000..7fe40ced9 --- /dev/null +++ b/vscode-extension/src/code-lens.ts @@ -0,0 +1,120 @@ +import * as vscode from "vscode"; +import type { GsdClient } from "./gsd-client.js"; + +/** + * Patterns that identify the start of a named function, class, or method + * declaration in common languages. Each entry captures the symbol name in + * capture group 1. + */ +const SYMBOL_PATTERNS: { languages: string[]; regex: RegExp }[] = [ + { + // TypeScript / JavaScript: function foo(...) | async function foo(...) + languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"], + regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/, + }, + { + // TypeScript / JavaScript: class Foo + languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"], + regex: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/, + }, + { + // TypeScript / JavaScript: method declarations inside a class + // foo(...) { | async foo(...) { | private foo(...): T { + languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"], + regex: /^\s*(?:(?:public|private|protected|static|async|readonly)\s+)*(\w+)\s*\(/, + }, + { + // Python: def foo( | async def foo( + languages: ["python"], + regex: /^\s*(?:async\s+)?def\s+(\w+)\s*\(/, + }, + { + // Python: class Foo + languages: ["python"], + regex: /^\s*class\s+(\w+)/, + }, + { + // Go: func foo( | func (r Receiver) foo( + languages: ["go"], + regex: /^\s*func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/, + }, + { + // Rust: fn foo( | pub fn foo( | async fn foo( + languages: ["rust"], + regex: /^\s*(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?fn\s+(\w+)\s*[(<]/, + }, +]; + +/** + * CodeLensProvider that adds an "Ask GSD" lens above named function and class + * declarations. Clicking the lens sends a brief explanation request to the GSD + * agent for that specific symbol. + */ +export class GsdCodeLensProvider implements vscode.CodeLensProvider, vscode.Disposable { + private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; + + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + this.disposables.push( + this._onDidChangeCodeLenses, + client.onConnectionChange(() => this._onDidChangeCodeLenses.fire()), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("gsd.codeLens")) { + this._onDidChangeCodeLenses.fire(); + } + }), + ); + } + + provideCodeLenses( + document: vscode.TextDocument, + _token: vscode.CancellationToken, + ): vscode.CodeLens[] { + const lenses: vscode.CodeLens[] = []; + + if (!vscode.workspace.getConfiguration("gsd").get("codeLens", true)) { + return lenses; + } + const langId = document.languageId; + const patterns = SYMBOL_PATTERNS.filter((p) => p.languages.includes(langId)); + + if (patterns.length === 0) { + return lenses; + } + + const fileName = document.fileName.split(/[\\/]/).pop() ?? document.fileName; + const seen = new Set(); + + for (let i = 0; i < document.lineCount; i++) { + const text = document.lineAt(i).text; + + for (const { regex } of patterns) { + const match = regex.exec(text); + if (match && match[1] && !seen.has(i)) { + seen.add(i); + const symbolName = match[1]; + const range = new vscode.Range(i, 0, i, text.length); + + lenses.push( + new vscode.CodeLens(range, { + title: "$(hubot) Ask GSD", + tooltip: `Ask GSD to explain ${symbolName}`, + command: "gsd.askAboutSymbol", + arguments: [symbolName, fileName, i + 1], + }), + ); + } + } + } + + return lenses; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } +} diff --git a/vscode-extension/src/conversation-history.ts b/vscode-extension/src/conversation-history.ts new file mode 100644 index 000000000..bebde2190 --- /dev/null +++ b/vscode-extension/src/conversation-history.ts @@ -0,0 +1,244 @@ +import * as vscode from "vscode"; +import type { GsdClient } from "./gsd-client.js"; + +interface ContentBlock { + type: string; + text?: string; + [key: string]: unknown; +} + +interface ConversationMessage { + role: "user" | "assistant" | "system"; + content: string | ContentBlock[]; +} + +/** + * Webview panel that displays the full conversation history for the + * current GSD session using the get_messages RPC call. + */ +export class GsdConversationHistoryPanel implements vscode.Disposable { + private static currentPanel: GsdConversationHistoryPanel | undefined; + + private readonly panel: vscode.WebviewPanel; + private readonly client: GsdClient; + private disposables: vscode.Disposable[] = []; + + static createOrShow( + extensionUri: vscode.Uri, + client: GsdClient, + ): GsdConversationHistoryPanel { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + + if (GsdConversationHistoryPanel.currentPanel) { + GsdConversationHistoryPanel.currentPanel.panel.reveal(column); + void GsdConversationHistoryPanel.currentPanel.refresh(); + return GsdConversationHistoryPanel.currentPanel; + } + + const panel = vscode.window.createWebviewPanel( + "gsd-history", + "GSD Conversation History", + column, + { + enableScripts: true, + retainContextWhenHidden: true, + }, + ); + + GsdConversationHistoryPanel.currentPanel = new GsdConversationHistoryPanel( + panel, + extensionUri, + client, + ); + void GsdConversationHistoryPanel.currentPanel.refresh(); + return GsdConversationHistoryPanel.currentPanel; + } + + private constructor( + panel: vscode.WebviewPanel, + _extensionUri: vscode.Uri, + client: GsdClient, + ) { + this.panel = panel; + this.client = client; + + this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + + this.panel.webview.onDidReceiveMessage( + async (msg: { command: string }) => { + if (msg.command === "refresh") { + await this.refresh(); + } + }, + null, + this.disposables, + ); + } + + async refresh(): Promise { + if (!this.client.isConnected) { + this.panel.webview.html = this.getHtml([], "Not connected to GSD agent."); + return; + } + + try { + const raw = await this.client.getMessages(); + this.panel.webview.html = this.getHtml(raw as ConversationMessage[]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.panel.webview.html = this.getHtml([], `Error loading messages: ${msg}`); + } + } + + dispose(): void { + GsdConversationHistoryPanel.currentPanel = undefined; + this.panel.dispose(); + for (const d of this.disposables) { + d.dispose(); + } + } + + private getHtml(messages: ConversationMessage[], errorMessage?: string): string { + const nonce = getNonce(); + + const renderedMessages = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((msg) => { + const text = extractText(msg.content); + if (!text.trim()) return ""; + const isUser = msg.role === "user"; + return `
+
${isUser ? "You" : "GSD"}
+
${escapeHtml(text)}
+
`; + }) + .filter(Boolean) + .join("\n"); + + return /* html */ ` + + + + + + + + +

Conversation History

+
+ + ${messages.length > 0 ? `${messages.length} message${messages.length === 1 ? "" : "s"}` : ""} +
+ ${errorMessage ? `
${escapeHtml(errorMessage)}
` : ""} + ${!errorMessage && renderedMessages === "" ? '
No messages in this session.
' : renderedMessages} + + +`; + } +} + +function extractText(content: string | ContentBlock[]): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((block) => { + if (typeof block === "string") return block; + if (block?.type === "text" && typeof block.text === "string") return block.text; + return ""; + }) + .join(""); + } + return ""; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function getNonce(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let i = 0; i < 32; i++) { + nonce += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return nonce; +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index ce89ab08e..f125cebd9 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -2,9 +2,17 @@ import * as vscode from "vscode"; import { GsdClient, ThinkingLevel } from "./gsd-client.js"; import { registerChatParticipant } from "./chat-participant.js"; import { GsdSidebarProvider } from "./sidebar.js"; +import { GsdFileDecorationProvider } from "./file-decorations.js"; +import { GsdBashTerminal } from "./bash-terminal.js"; +import { GsdSessionTreeProvider } from "./session-tree.js"; +import { GsdConversationHistoryPanel } from "./conversation-history.js"; +import { GsdSlashCompletionProvider } from "./slash-completion.js"; +import { GsdCodeLensProvider } from "./code-lens.js"; let client: GsdClient | undefined; let sidebarProvider: GsdSidebarProvider | undefined; +let fileDecorations: GsdFileDecorationProvider | undefined; +let sessionTreeProvider: GsdSessionTreeProvider | undefined; function requireConnected(): boolean { if (!client?.isConnected) { @@ -35,7 +43,43 @@ export function activate(context: vscode.ExtensionContext): void { outputChannel.appendLine(`[stderr] ${msg}`); }); - client.onConnectionChange((connected) => { + // -- Persistent status bar item ---------------------------------------- + + const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + statusBarItem.command = "workbench.view.extension.gsd"; + statusBarItem.text = "$(hubot) GSD"; + statusBarItem.tooltip = "GSD Agent — click to open"; + statusBarItem.show(); + context.subscriptions.push(statusBarItem); + + async function refreshStatusBar(): Promise { + if (!client?.isConnected) { + statusBarItem.text = "$(hubot) GSD"; + statusBarItem.tooltip = "GSD: Disconnected"; + return; + } + try { + const [state, stats] = await Promise.all([ + client.getState().catch(() => null), + client.getSessionStats().catch(() => null), + ]); + const modelId = state?.model?.id ?? ""; + const costPart = stats?.totalCost !== undefined ? ` | $${stats.totalCost.toFixed(4)}` : ""; + const streamPart = state?.isStreaming ? " $(sync~spin)" : ""; + statusBarItem.text = `$(hubot) GSD${modelId ? ` | ${modelId}` : ""}${costPart}${streamPart}`; + statusBarItem.tooltip = state?.model + ? `GSD: Connected — ${state.model.provider}/${state.model.id}` + : "GSD: Connected"; + } catch { + // ignore fetch errors + } + } + + const statusBarTimer = setInterval(() => refreshStatusBar(), 10_000); + context.subscriptions.push({ dispose: () => clearInterval(statusBarTimer) }); + + client.onConnectionChange(async (connected) => { + await refreshStatusBar(); if (connected) { vscode.window.setStatusBarMessage("$(hubot) GSD connected", 3000); } else { @@ -53,10 +97,73 @@ export function activate(context: vscode.ExtensionContext): void { ), ); + // -- File decorations -------------------------------------------------- + + fileDecorations = new GsdFileDecorationProvider(client); + context.subscriptions.push( + fileDecorations, + vscode.window.registerFileDecorationProvider(fileDecorations), + ); + + // -- Bash terminal ----------------------------------------------------- + + const bashTerminal = new GsdBashTerminal(client); + context.subscriptions.push(bashTerminal); + + // -- Session tree view ------------------------------------------------- + + sessionTreeProvider = new GsdSessionTreeProvider(client); + context.subscriptions.push( + sessionTreeProvider, + vscode.window.registerTreeDataProvider(GsdSessionTreeProvider.viewId, sessionTreeProvider), + ); + // -- Chat participant --------------------------------------------------- context.subscriptions.push(registerChatParticipant(context, client)); + // -- Conversation history panel ---------------------------------------- + + // (panel is created on demand via gsd.showHistory command) + + // -- Slash command completion ------------------------------------------ + + const slashCompletion = new GsdSlashCompletionProvider(client); + context.subscriptions.push( + slashCompletion, + vscode.languages.registerCompletionItemProvider( + [ + { language: "markdown" }, + { language: "plaintext" }, + { language: "typescript" }, + { language: "typescriptreact" }, + { language: "javascript" }, + { language: "javascriptreact" }, + ], + slashCompletion, + "/", + ), + ); + + // -- Code lens "Ask GSD" ----------------------------------------------- + + const codeLensProvider = new GsdCodeLensProvider(client); + context.subscriptions.push( + codeLensProvider, + vscode.languages.registerCodeLensProvider( + [ + { language: "typescript" }, + { language: "typescriptreact" }, + { language: "javascript" }, + { language: "javascriptreact" }, + { language: "python" }, + { language: "go" }, + { language: "rust" }, + ], + codeLensProvider, + ), + ); + // -- Commands ----------------------------------------------------------- // Start @@ -68,6 +175,7 @@ export function activate(context: vscode.ExtensionContext): void { const autoCompaction = vscode.workspace.getConfiguration("gsd").get("autoCompaction", true); await client!.setAutoCompaction(autoCompaction).catch(() => {}); sidebarProvider?.refresh(); + refreshStatusBar(); vscode.window.showInformationMessage("GSD agent started."); } catch (err) { handleError(err, "Failed to start GSD"); @@ -91,6 +199,8 @@ export function activate(context: vscode.ExtensionContext): void { try { await client!.newSession(); sidebarProvider?.refresh(); + sessionTreeProvider?.refresh(); + fileDecorations?.clear(); vscode.window.showInformationMessage("New GSD session started."); } catch (err) { handleError(err, "Failed to start new session"); @@ -344,6 +454,132 @@ export function activate(context: vscode.ExtensionContext): void { }), ); + // Switch Session + context.subscriptions.push( + vscode.commands.registerCommand("gsd.switchSession", async (sessionFile?: string) => { + if (!requireConnected()) return; + const file = sessionFile ?? await (async () => { + const input = await vscode.window.showInputBox({ + prompt: "Enter session file path", + placeHolder: "/path/to/session.jsonl", + }); + return input; + })(); + if (!file) return; + try { + await client!.switchSession(file); + sidebarProvider?.refresh(); + sessionTreeProvider?.refresh(); + vscode.window.showInformationMessage("Switched session."); + } catch (err) { + handleError(err, "Failed to switch session"); + } + }), + ); + + // Refresh Sessions + context.subscriptions.push( + vscode.commands.registerCommand("gsd.refreshSessions", () => { + sessionTreeProvider?.refresh(); + }), + ); + + // Show Conversation History + context.subscriptions.push( + vscode.commands.registerCommand("gsd.showHistory", () => { + if (!requireConnected()) return; + GsdConversationHistoryPanel.createOrShow(context.extensionUri, client!); + }), + ); + + // Ask About Symbol (triggered by code lens) + context.subscriptions.push( + vscode.commands.registerCommand( + "gsd.askAboutSymbol", + async (symbolName: string, fileName: string, lineNumber: number) => { + if (!requireConnected()) return; + try { + const prompt = `Explain the \`${symbolName}\` function/class in ${fileName} (line ${lineNumber}). Be concise.`; + await client!.sendPrompt(prompt); + } catch (err) { + handleError(err, "Failed to send Ask GSD request"); + } + }, + ), + ); + + // Clear File Decorations + context.subscriptions.push( + vscode.commands.registerCommand("gsd.clearFileDecorations", () => { + fileDecorations?.clear(); + }), + ); + + // Toggle Auto-Retry + context.subscriptions.push( + vscode.commands.registerCommand("gsd.toggleAutoRetry", async () => { + if (!requireConnected()) return; + try { + const next = !client!.autoRetryEnabled; + await client!.setAutoRetry(next); + vscode.window.showInformationMessage(`Auto-retry ${next ? "enabled" : "disabled"}.`); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to toggle auto-retry"); + } + }), + ); + + // Abort Retry + context.subscriptions.push( + vscode.commands.registerCommand("gsd.abortRetry", async () => { + if (!requireConnected()) return; + try { + await client!.abortRetry(); + vscode.window.showInformationMessage("Retry aborted."); + } catch (err) { + handleError(err, "Failed to abort retry"); + } + }), + ); + + // Set Session Name + context.subscriptions.push( + vscode.commands.registerCommand("gsd.setSessionName", async () => { + if (!requireConnected()) return; + const name = await vscode.window.showInputBox({ + prompt: "Enter a name for this session", + placeHolder: "e.g. auth-refactor", + }); + if (!name) return; + try { + await client!.setSessionName(name); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage(`Session named "${name}".`); + } catch (err) { + handleError(err, "Failed to set session name"); + } + }), + ); + + // Copy Last Response + context.subscriptions.push( + vscode.commands.registerCommand("gsd.copyLastResponse", async () => { + if (!requireConnected()) return; + try { + const text = await client!.getLastAssistantText(); + if (!text) { + vscode.window.showInformationMessage("No response to copy."); + return; + } + await vscode.env.clipboard.writeText(text); + vscode.window.showInformationMessage("Last response copied to clipboard."); + } catch (err) { + handleError(err, "Failed to copy last response"); + } + }), + ); + // -- Auto-start --------------------------------------------------------- if (config.get("autoStart", false)) { @@ -354,6 +590,10 @@ export function activate(context: vscode.ExtensionContext): void { export function deactivate(): void { client?.dispose(); sidebarProvider?.dispose(); + fileDecorations?.dispose(); + sessionTreeProvider?.dispose(); client = undefined; sidebarProvider = undefined; + fileDecorations = undefined; + sessionTreeProvider = undefined; } diff --git a/vscode-extension/src/file-decorations.ts b/vscode-extension/src/file-decorations.ts new file mode 100644 index 000000000..74f48c994 --- /dev/null +++ b/vscode-extension/src/file-decorations.ts @@ -0,0 +1,84 @@ +import * as vscode from "vscode"; +import type { AgentEvent, GsdClient } from "./gsd-client.js"; + +/** + * Badges files in the VS Code explorer that GSD has written or edited + * during the current session. + */ +export class GsdFileDecorationProvider implements vscode.FileDecorationProvider, vscode.Disposable { + private readonly _onDidChangeFileDecorations = new vscode.EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + private modifiedUris = new Set(); + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + this.disposables.push( + this._onDidChangeFileDecorations, + client.onEvent((evt: AgentEvent) => this.handleEvent(evt)), + client.onConnectionChange((connected) => { + if (!connected) { + this.clear(); + } + }), + ); + } + + private handleEvent(evt: AgentEvent): void { + if (evt.type !== "tool_execution_start") { + return; + } + const toolName = evt.toolName as string | undefined; + if (toolName !== "Write" && toolName !== "Edit") { + return; + } + const toolInput = evt.toolInput as Record | undefined; + const fp = toolInput?.file_path ? String(toolInput.file_path) : undefined; + if (!fp) { + return; + } + const uri = resolveUri(fp); + if (uri) { + this.modifiedUris.add(uri.toString()); + this._onDidChangeFileDecorations.fire(uri); + } + } + + provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { + if (this.modifiedUris.has(uri.toString())) { + return { + badge: "G", + tooltip: "Modified by GSD", + color: new vscode.ThemeColor("gitDecoration.modifiedResourceForeground"), + }; + } + return undefined; + } + + clear(): void { + this.modifiedUris.clear(); + this._onDidChangeFileDecorations.fire(undefined); + } + + dispose(): void { + this.clear(); + for (const d of this.disposables) { + d.dispose(); + } + } +} + +function resolveUri(fp: string): vscode.Uri | null { + try { + if (fp.startsWith("/") || /^[A-Za-z]:[\\/]/.test(fp)) { + return vscode.Uri.file(fp); + } + const folders = vscode.workspace.workspaceFolders; + if (!folders?.length) { + return null; + } + return vscode.Uri.joinPath(folders[0].uri, fp); + } catch { + return null; + } +} diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts index 29237dc24..2e37befa2 100644 --- a/vscode-extension/src/gsd-client.ts +++ b/vscode-extension/src/gsd-client.ts @@ -87,6 +87,7 @@ export class GsdClient implements vscode.Disposable { private buffer = ""; private restartCount = 0; private restartTimestamps: number[] = []; + private _autoRetryEnabled = false; private readonly _onEvent = new vscode.EventEmitter(); readonly onEvent = this._onEvent.event; @@ -110,6 +111,10 @@ export class GsdClient implements vscode.Disposable { return this.process !== null && this.process.exitCode === null; } + get autoRetryEnabled(): boolean { + return this._autoRetryEnabled; + } + /** * Spawn the GSD agent in RPC mode. */ @@ -377,6 +382,7 @@ export class GsdClient implements vscode.Disposable { async setAutoRetry(enabled: boolean): Promise { const response = await this.send({ type: "set_auto_retry", enabled }); this.assertSuccess(response); + this._autoRetryEnabled = enabled; } /** @@ -418,6 +424,7 @@ export class GsdClient implements vscode.Disposable { async newSession(): Promise { const response = await this.send({ type: "new_session" }); this.assertSuccess(response); + this._autoRetryEnabled = false; } /** diff --git a/vscode-extension/src/session-tree.ts b/vscode-extension/src/session-tree.ts new file mode 100644 index 000000000..e61898e0a --- /dev/null +++ b/vscode-extension/src/session-tree.ts @@ -0,0 +1,126 @@ +import * as vscode from "vscode"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { GsdClient } from "./gsd-client.js"; + +export interface SessionItem { + label: string; + sessionFile: string; + timestamp: Date; + sessionId: string; + isCurrent: boolean; +} + +/** + * Tree view provider that lists GSD session files from the same directory + * as the currently active session. + */ +export class GsdSessionTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + public static readonly viewId = "gsd-sessions"; + + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private sessions: SessionItem[] = []; + private currentSessionFile: string | undefined; + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + this.disposables.push( + this._onDidChangeTreeData, + client.onConnectionChange(() => this.refresh()), + ); + } + + async refresh(): Promise { + this.sessions = await this.loadSessions(); + this._onDidChangeTreeData.fire(); + } + + private async loadSessions(): Promise { + if (!this.client.isConnected) { + return []; + } + try { + const state = await this.client.getState(); + this.currentSessionFile = state.sessionFile; + if (!state.sessionFile) { + return []; + } + + const sessionDir = path.dirname(state.sessionFile); + const files = fs.readdirSync(sessionDir) + .filter((f) => f.endsWith(".jsonl")) + .sort() + .reverse(); // newest first + + const items: SessionItem[] = []; + for (const file of files) { + // Filename format: _.jsonl + const match = file.match(/^(\d+)_(.+)\.jsonl$/); + if (!match) { + continue; + } + const ts = parseInt(match[1], 10); + const sessionId = match[2]; + const sessionFile = path.join(sessionDir, file); + items.push({ + label: formatDate(new Date(ts)), + sessionFile, + timestamp: new Date(ts), + sessionId, + isCurrent: sessionFile === state.sessionFile, + }); + } + return items; + } catch { + return []; + } + } + + getTreeItem(element: SessionItem): vscode.TreeItem { + const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.None); + item.description = element.sessionId.slice(0, 8); + item.tooltip = new vscode.MarkdownString( + `**${element.label}**\n\nID: \`${element.sessionId}\`\n\nFile: \`${element.sessionFile}\``, + ); + item.iconPath = new vscode.ThemeIcon( + element.isCurrent ? "comment-discussion" : "history", + element.isCurrent ? new vscode.ThemeColor("terminal.ansiGreen") : undefined, + ); + if (!element.isCurrent) { + item.command = { + command: "gsd.switchSession", + title: "Switch to Session", + arguments: [element.sessionFile], + }; + } + item.contextValue = element.isCurrent ? "currentSession" : "session"; + return item; + } + + getChildren(): SessionItem[] { + return this.sessions; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } +} + +function formatDate(d: Date): string { + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffDays === 0) { + return `Today ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; + } else if (diffDays === 1) { + return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; + } else if (diffDays < 7) { + return d.toLocaleDateString([], { weekday: "short", hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); +} diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts index 961c56d0d..f8a8e55ec 100644 --- a/vscode-extension/src/sidebar.ts +++ b/vscode-extension/src/sidebar.ts @@ -19,9 +19,17 @@ 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(); + switch (evt.type) { + case "agent_start": + case "agent_end": + case "model_switched": + case "compaction_start": + case "compaction_end": + case "retry_start": + case "retry_end": + case "retry_error": + this.refresh(); + break; } }), ); @@ -85,6 +93,18 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { } } break; + case "toggleAutoRetry": + if (this.client.isConnected) { + await this.client.setAutoRetry(!this.client.autoRetryEnabled).catch(() => {}); + this.refresh(); + } + break; + case "setSessionName": + await vscode.commands.executeCommand("gsd.setSessionName"); + break; + case "copyLastResponse": + await vscode.commands.executeCommand("gsd.copyLastResponse"); + break; } }); @@ -107,13 +127,16 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { let sessionId = "N/A"; let sessionName = ""; let messageCount = 0; + let pendingMessageCount = 0; let thinkingLevel: ThinkingLevel = "off"; let isStreaming = false; let isCompacting = false; let autoCompaction = false; + let autoRetry = false; let stats: SessionStats | null = null; if (this.client.isConnected) { + autoRetry = this.client.autoRetryEnabled; try { const state = await this.client.getState(); modelName = state.model @@ -122,6 +145,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { sessionId = state.sessionId; sessionName = state.sessionName ?? ""; messageCount = state.messageCount; + pendingMessageCount = state.pendingMessageCount; thinkingLevel = state.thinkingLevel as ThinkingLevel; isStreaming = state.isStreaming; isCompacting = state.isCompacting; @@ -145,10 +169,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { sessionId, sessionName, messageCount, + pendingMessageCount, thinkingLevel, isStreaming, isCompacting, autoCompaction, + autoRetry, stats, }); } @@ -168,10 +194,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { sessionId: string; sessionName: string; messageCount: number; + pendingMessageCount: number; thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; autoCompaction: boolean; + autoRetry: boolean; stats: SessionStats | null; }): string { const statusColor = info.connected ? "#4ec9b0" : "#f44747"; @@ -185,6 +213,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { const inputTokens = info.stats?.inputTokens?.toLocaleString() ?? "-"; const outputTokens = info.stats?.outputTokens?.toLocaleString() ?? "-"; + const cacheRead = info.stats?.cacheReadTokens?.toLocaleString() ?? "-"; + const cacheWrite = info.stats?.cacheWriteTokens?.toLocaleString() ?? "-"; + const turnCount = info.stats?.turnCount?.toString() ?? "-"; + const duration = info.stats?.duration !== undefined + ? `${Math.round(info.stats.duration / 1000)}s` + : "-"; const cost = info.stats?.totalCost !== undefined ? `$${info.stats.totalCost.toFixed(4)}` : "-"; const thinkingBadge = info.thinkingLevel !== "off" @@ -195,6 +229,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { ? `on` : `off`; + const autoRetryBadge = info.autoRetry + ? `on` + : `off`; + const streamingIndicator = info.isStreaming ? `
Agent is working...
` : ""; @@ -352,8 +390,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
Session
- - + + + + + @@ -362,6 +406,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { + + + +
Model${escapeHtml(info.modelName)}
Session${escapeHtml(info.sessionName || info.sessionId)}
Messages${info.messageCount}
Session + ${escapeHtml(info.sessionName || info.sessionId)} + ${info.connected ? `` : ""} +
Messages${info.messageCount}${info.pendingMessageCount > 0 ? ` +${info.pendingMessageCount} pending` : ""}
Thinking ${thinkingBadge}Auto-compact ${autoCompBadge}
Auto-retry${autoRetryBadge}
@@ -373,6 +421,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { ${inputTokens} Output ${outputTokens} + Cache read + ${cacheRead} + Cache write + ${cacheWrite} + Turns + ${turnCount} + Duration + ${duration} Cost ${cost} @@ -391,6 +447,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
+
+
+ +
` : `` } diff --git a/vscode-extension/src/slash-completion.ts b/vscode-extension/src/slash-completion.ts new file mode 100644 index 000000000..ce9885dd5 --- /dev/null +++ b/vscode-extension/src/slash-completion.ts @@ -0,0 +1,107 @@ +import * as vscode from "vscode"; +import type { GsdClient, SlashCommand } from "./gsd-client.js"; + +/** + * CompletionItemProvider that surfaces GSD slash commands when the user + * types `/` at the start of a line (or after only whitespace) in Markdown, + * plaintext, and TypeScript/JavaScript files. + * + * Commands are fetched from the running agent via get_commands RPC and + * cached so the list remains available between keystrokes. + */ +export class GsdSlashCompletionProvider + implements vscode.CompletionItemProvider, vscode.Disposable +{ + private cachedCommands: SlashCommand[] = []; + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + // Refresh cache whenever the connection (re)establishes. + this.disposables.push( + client.onConnectionChange(async (connected) => { + if (connected) { + await this.refreshCache(); + } else { + this.cachedCommands = []; + } + }), + ); + } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + ): Promise { + const lineText = document.lineAt(position).text; + const linePrefix = lineText.slice(0, position.character); + + // Only activate when the non-whitespace content starts with `/`. + if (!/^\s*\/\S*$/.test(linePrefix)) { + return undefined; + } + + // Lazily populate the cache on first use. + if (this.cachedCommands.length === 0 && this.client.isConnected) { + await this.refreshCache(); + } + + if (this.cachedCommands.length === 0) { + return undefined; + } + + // The text the user has typed after the `/` — used for pre-filtering. + const slashIndex = linePrefix.lastIndexOf("/"); + const typedAfterSlash = linePrefix.slice(slashIndex + 1); + + // Range to replace: from the `/` to the current cursor position. + const replaceRange = new vscode.Range( + new vscode.Position(position.line, slashIndex), + position, + ); + + return this.cachedCommands + .filter( + (cmd) => + typedAfterSlash.length === 0 || + cmd.name.toLowerCase().startsWith(typedAfterSlash.toLowerCase()), + ) + .map((cmd) => this.toCompletionItem(cmd, replaceRange)); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } + + private async refreshCache(): Promise { + try { + this.cachedCommands = await this.client.getCommands(); + } catch { + // Silently ignore — agent may not be ready yet. + } + } + + private toCompletionItem(cmd: SlashCommand, replaceRange: vscode.Range): vscode.CompletionItem { + const item = new vscode.CompletionItem(`/${cmd.name}`, vscode.CompletionItemKind.Event); + + item.insertText = `/${cmd.name}`; + item.filterText = `/${cmd.name}`; + item.sortText = cmd.name; + item.range = replaceRange; + item.commitCharacters = [" ", "\n"]; + + const sourceNote = `Source: \`${cmd.source}\`${cmd.location ? ` (${cmd.location})` : ""}`; + if (cmd.description) { + item.detail = cmd.description; + item.documentation = new vscode.MarkdownString( + `**/${cmd.name}** — ${cmd.description}\n\n${sourceNote}`, + ); + } else { + item.documentation = new vscode.MarkdownString(`**/${cmd.name}**\n\n${sourceNote}`); + } + + return item; + } +}