feat(vscode): status bar, file decorations, bash terminal, session tree, conversation history, code lens [1/2] (#2651)

* feat(vscode): status bar, auto-retry, session name, copy response, keyboard shortcuts, full stats

* feat(vscode): file decorations, bash terminal, session tree view

* feat(vscode): conversation history webview, slash completion, code lens

- conversation-history.ts: GsdConversationHistoryPanel webview panel using
  getMessages() RPC; renders user/assistant turns with a Refresh button
- slash-completion.ts: GsdSlashCompletionProvider triggers on '/' at line
  start in md/plaintext/ts/js; fetches getCommands() RPC and caches results
- code-lens.ts: GsdCodeLensProvider adds 'Ask GSD' lens above named
  functions/classes in ts/js/py/go/rust; respects gsd.codeLens setting
- extension.ts: registers all three providers and new commands
  (gsd.showHistory, gsd.askAboutSymbol)
- package.json: declares new commands and gsd.codeLens config toggle
This commit is contained in:
Jeremy McSpadden 2026-03-26 17:18:37 -05:00 committed by GitHub
parent 12713a547c
commit c2cd8bcc0a
10 changed files with 1149 additions and 7 deletions

View file

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

View file

@ -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<string> | 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<string> } {
if (!this.terminal || this.terminal.exitStatus !== undefined) {
this.writeEmitter?.dispose();
this.writeEmitter = new vscode.EventEmitter<string>();
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<string, unknown> | 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();
}
}
}

View file

@ -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<void>();
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<boolean>("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<number>();
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();
}
}
}

View file

@ -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<void> {
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 `<div class="message ${isUser ? "user" : "assistant"}">
<div class="role">${isUser ? "You" : "GSD"}</div>
<div class="content">${escapeHtml(text)}</div>
</div>`;
})
.filter(Boolean)
.join("\n");
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<style>
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
padding: 16px;
margin: 0;
}
h2 {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.btn {
padding: 5px 12px;
border: none;
border-radius: 2px;
cursor: pointer;
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
}
.btn:hover { background: var(--vscode-button-hoverBackground); }
.count {
font-size: 12px;
opacity: 0.6;
}
.error {
color: var(--vscode-errorForeground);
padding: 10px 12px;
background: var(--vscode-inputValidation-errorBackground);
border-radius: 4px;
margin-bottom: 12px;
}
.empty {
opacity: 0.55;
font-style: italic;
}
.message {
margin-bottom: 14px;
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
}
.role {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 3px 10px;
background: var(--vscode-panel-border);
opacity: 0.85;
}
.message.assistant .role {
background: var(--vscode-focusBorder);
color: var(--vscode-button-foreground);
opacity: 1;
}
.content {
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
</style>
</head>
<body>
<h2>Conversation History</h2>
<div class="toolbar">
<button class="btn" id="refresh">Refresh</button>
${messages.length > 0 ? `<span class="count">${messages.length} message${messages.length === 1 ? "" : "s"}</span>` : ""}
</div>
${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ""}
${!errorMessage && renderedMessages === "" ? '<div class="empty">No messages in this session.</div>' : renderedMessages}
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
document.getElementById('refresh').addEventListener('click', () => {
vscode.postMessage({ command: 'refresh' });
});
</script>
</body>
</html>`;
}
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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;
}

View file

@ -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<void> {
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<boolean>("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<boolean>("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;
}

View file

@ -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<vscode.Uri | vscode.Uri[] | undefined>();
readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event;
private modifiedUris = new Set<string>();
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<string, unknown> | 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;
}
}

View file

@ -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<AgentEvent>();
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<void> {
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<void> {
const response = await this.send({ type: "new_session" });
this.assertSuccess(response);
this._autoRetryEnabled = false;
}
/**

View file

@ -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<SessionItem>, vscode.Disposable {
public static readonly viewId = "gsd-sessions";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
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<void> {
this.sessions = await this.loadSessions();
this._onDidChangeTreeData.fire();
}
private async loadSessions(): Promise<SessionItem[]> {
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: <unixTimestampMs>_<sessionId>.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" });
}

View file

@ -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 {
? `<span class="badge">on</span>`
: `<span class="badge muted">off</span>`;
const autoRetryBadge = info.autoRetry
? `<span class="badge">on</span>`
: `<span class="badge muted">off</span>`;
const streamingIndicator = info.isStreaming
? `<div class="streaming-indicator"><span class="spinner"></span> Agent is working...</div>`
: "";
@ -352,8 +390,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<div class="section-title">Session</div>
<table class="info-table">
<tr><td>Model</td><td>${escapeHtml(info.modelName)}</td></tr>
<tr><td>Session</td><td>${escapeHtml(info.sessionName || info.sessionId)}</td></tr>
<tr><td>Messages</td><td>${info.messageCount}</td></tr>
<tr>
<td>Session</td>
<td>
${escapeHtml(info.sessionName || info.sessionId)}
${info.connected ? `<span class="badge clickable" data-command="setSessionName" title="Rename session" style="margin-left:4px">✎</span>` : ""}
</td>
</tr>
<tr><td>Messages</td><td>${info.messageCount}${info.pendingMessageCount > 0 ? ` <span class="badge muted">+${info.pendingMessageCount} pending</span>` : ""}</td></tr>
<tr>
<td>Thinking</td>
<td>${thinkingBadge}</td>
@ -362,6 +406,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<td>Auto-compact</td>
<td>${autoCompBadge}</td>
</tr>
<tr>
<td>Auto-retry</td>
<td>${autoRetryBadge}</td>
</tr>
</table>
</div>
@ -373,6 +421,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<span class="value">${inputTokens}</span>
<span class="label">Output</span>
<span class="value">${outputTokens}</span>
<span class="label">Cache read</span>
<span class="value">${cacheRead}</span>
<span class="label">Cache write</span>
<span class="value">${cacheWrite}</span>
<span class="label">Turns</span>
<span class="value">${turnCount}</span>
<span class="label">Duration</span>
<span class="value">${duration}</span>
<span class="label">Cost</span>
<span class="value">${cost}</span>
</div>
@ -391,6 +447,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<div class="btn-row">
<button class="secondary" data-command="cycleThinking">Thinking</button>
<button class="secondary" data-command="toggleAutoCompaction">Auto-Compact</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="toggleAutoRetry">Auto-Retry</button>
<button class="secondary" data-command="copyLastResponse">Copy Response</button>
</div>`
: `<button data-command="start">Start Agent</button>`
}

View file

@ -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<vscode.CompletionItem[] | undefined> {
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<void> {
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;
}
}