diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md index fd532537d..98266e301 100644 --- a/vscode-extension/CHANGELOG.md +++ b/vscode-extension/CHANGELOG.md @@ -1,24 +1,45 @@ # Changelog +## [0.3.0] + +### Added + +- **SCM provider** — "GSD Agent" appears in Source Control panel with accept/discard per-file diffs +- **Change tracker** — captures original file content before agent modifications for diff and rollback +- **Checkpoints** — automatic snapshots on each agent turn with restore capability +- **Diagnostic bridge** — "Fix Problems in File" and "Fix All Problems" commands read VS Code diagnostics and send to agent +- **Line-level decorations** — green/yellow highlights on agent-modified lines with gutter indicators +- **Chat context injection** — auto-includes editor selection and file diagnostics when relevant +- **Git integration** — commit agent changes, create branches, show diffs +- **Approval modes** — auto-approve, ask (prompts before writes), plan-only (read-only) +- **UI request handling** — agent questions, confirmations, and selections now show as VS Code dialogs instead of hanging +- **Fix Errors button** — quick access to diagnostic fixing in sidebar Actions +- **5 new settings** — `showProgressNotifications`, `activityFeedMaxItems`, `showContextWarning`, `contextWarningThreshold`, `approvalMode` + +### Changed + +- **Sidebar redesign** — compact card-based layout with collapsible sections, pill toggles, hidden empty data +- **Workflow buttons** now route through Chat panel so responses are visible +- **Slash completion** filtered to `/gsd` commands only +- **Checkpoint labels** show timestamp + first action (e.g., "10:32 — Edit sidebar.ts") +- **Session tree** supports ISO timestamp filenames (GSD's actual format) +- **Session persistence** enabled (removed `--no-session` flag) +- **Progress notifications** disabled by default (Chat panel provides inline progress) +- **Sidebar reduced** from 6 panels to 3 (GSD Agent, Sessions, Activity) +- **Settings section** starts collapsed by default + ## [0.2.0] ### Added -- **Activity feed** — real-time TreeView showing tool executions (Read, Write, Edit, Bash, Grep, Glob) with status icons, duration, and click-to-open -- **Workflow controls** — sidebar buttons for Auto, Next, Quick Task, Capture, Status, and Fork that send `/gsd` slash commands -- **Progress notifications** — VS Code notification with cancel button while the agent is working -- **Context window indicator** — color-coded usage bar (green/yellow/red) in sidebar with configurable threshold warnings -- **Session forking** — fork from any message via QuickPick using `get_fork_messages` and `fork` RPC commands -- **Queue mode controls** — toggle steering and follow-up modes (all vs one-at-a-time) from the sidebar -- **Enhanced conversation history** — tool call rendering, collapsible thinking blocks, search/filter, fork-from-here buttons -- **Enhanced code lens** — Refactor, Find Bugs, and Generate Tests actions alongside Ask GSD -- **4 new settings** — `showProgressNotifications`, `activityFeedMaxItems`, `showContextWarning`, `contextWarningThreshold` -- **8 new commands** (33 total) — `clearActivity`, `forkSession`, `toggleSteeringMode`, `toggleFollowUpMode`, `refactorSymbol`, `findBugsSymbol`, `generateTestsSymbol` - -### Changed - -- Sidebar session table now shows steering and follow-up queue mode with clickable toggle badges -- Token usage section includes context window usage bar when model context window is known +- **Activity feed** — real-time TreeView showing tool executions with status icons, duration, and click-to-open +- **Workflow controls** — sidebar buttons for Auto, Next, Quick Task, Capture +- **Context window indicator** — color-coded usage bar in sidebar with threshold warnings +- **Session forking** — fork from any message via QuickPick +- **Queue mode controls** — toggle steering and follow-up modes from the sidebar +- **Enhanced conversation history** — tool call rendering, collapsible thinking blocks, search/filter, fork-from-here +- **Enhanced code lens** — Refactor, Find Bugs, and Generate Tests alongside Ask GSD +- **8 new commands** (33 total) ## [0.1.0] @@ -31,7 +52,7 @@ Initial release. - Bash terminal — pseudoterminal routing agent Bash tool output - Session tree — browse and switch between session files - Conversation history — webview panel with full chat log -- Slash command completion — auto-complete for `/gsd` commands in editors +- Slash command completion — auto-complete for `/gsd` commands - Code lens — "Ask GSD" above functions and classes in TS/JS/Python/Go/Rust - 25 commands with 6 keyboard shortcuts - Auto-start, auto-compaction, and code lens configuration diff --git a/vscode-extension/README.md b/vscode-extension/README.md index f0f249c43..899012880 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -1,88 +1,193 @@ # GSD-2 — VS Code Extension -Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd` in VS Code Chat, and monitor your agent from a sidebar dashboard — all without leaving the editor. +Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd`, monitor agent activity in real-time, review and accept/reject changes, and manage your workflow — all without leaving the editor. + +![GSD Extension Overview](docs/images/overview.png) ## Requirements -GSD must be installed before activating this extension: - -```bash -npm install -g gsd-pi -``` - -Node.js ≥ 22.0.0 and Git are required. - -## Features - -### Sidebar Dashboard - -Click the GSD icon in the Activity Bar to open the agent dashboard. It shows: - -- Connection status (connected / disconnected) -- Active model and provider -- Thinking level -- Token usage and session cost -- Quick action buttons: Start, Stop, New Session, Compact, Abort - -### Chat Integration (`@gsd`) - -Use `@gsd` in VS Code Chat (`Ctrl+Shift+I`) to send messages to the agent: - -``` -@gsd refactor the auth module to use JWT -@gsd /gsd auto -@gsd what's the current milestone status? -``` - -### Commands - -All commands are accessible via `Ctrl+Shift+P`: - -| Command | Description | -|---------|-------------| -| **GSD: Start Agent** | Connect to the GSD agent | -| **GSD: Stop Agent** | Disconnect the agent | -| **GSD: New Session** | Start a fresh conversation | -| **GSD: Send Message** | Send a message to the agent | -| **GSD: Abort Current Operation** | Interrupt the current operation | -| **GSD: Steer Agent** | Send a steering message mid-operation | -| **GSD: Switch Model** | Pick a model from QuickPick | -| **GSD: Cycle Model** | Rotate to the next configured model | -| **GSD: Set Thinking Level** | Choose off / low / medium / high | -| **GSD: Cycle Thinking Level** | Rotate through thinking levels | -| **GSD: Compact Context** | Manually trigger context compaction | -| **GSD: Export Conversation as HTML** | Save the session as HTML | -| **GSD: Show Session Stats** | Display token usage and cost | -| **GSD: Run Bash Command** | Execute a shell command via the agent | -| **GSD: List Available Commands** | Browse and run GSD slash commands | - -### Keyboard Shortcuts - -| Shortcut | Command | -|----------|---------| -| `Ctrl+Shift+G Ctrl+Shift+N` | New Session | -| `Ctrl+Shift+G Ctrl+Shift+M` | Cycle Model | -| `Ctrl+Shift+G Ctrl+Shift+T` | Cycle Thinking Level | - -## Configuration - -| Setting | Default | Description | -|---------|---------|-------------| -| `gsd.binaryPath` | `"gsd"` | Path to the GSD binary if not on PATH | -| `gsd.autoStart` | `false` | Start the agent automatically when the extension activates | -| `gsd.autoCompaction` | `true` | Enable automatic context compaction | +- **GSD-2** installed globally: `npm install -g gsd-pi` +- **Node.js** >= 22.0.0 +- **Git** installed and on PATH +- **VS Code** >= 1.95.0 ## Quick Start 1. Install GSD: `npm install -g gsd-pi` 2. Install this extension 3. Open a project folder in VS Code -4. `Ctrl+Shift+P` → **GSD: Start Agent** -5. Use `@gsd` in Chat or the sidebar to interact with the agent +4. Click the **GSD icon** in the Activity Bar (left sidebar) +5. Click **Start Agent** or run `Ctrl+Shift+P` > **GSD: Start Agent** +6. Start chatting with `@gsd` in Chat or click **Auto** in the sidebar + +--- + +## Features + +### Sidebar Dashboard + +Click the **GSD icon** in the Activity Bar. The compact header shows connection status, model, session, message count, thinking level, context usage bar, and cost — all in two lines. Sections (Workflow, Stats, Actions, Settings) are collapsible and remember their state. + +### Workflow Controls + +One-click buttons for GSD's core commands. All route through the Chat panel so you see the full response: + +| Button | What it does | +|--------|-------------| +| **Auto** | Start autonomous mode — research, plan, execute | +| **Next** | Execute one unit of work, then pause | +| **Quick** | Quick task without planning (opens input) | +| **Capture** | Capture a thought for later triage | + +### Chat Integration (`@gsd`) + +Use `@gsd` in VS Code Chat (`Cmd+Shift+I`) to talk to the agent: + +``` +@gsd refactor the auth module to use JWT +@gsd /gsd auto +@gsd fix the errors in this file +``` + +- **Auto-starts** the agent if not running +- **File context** via `#file` references +- **Selection context** — automatically includes selected code +- **Diagnostic context** — auto-includes errors/warnings when you mention "fix" or "error" +- **Streaming** progress, file anchors, token usage footer + +### Source Control Integration + +Agent-modified files appear in a dedicated **"GSD Agent"** section of the Source Control panel: + +- **Click any file** to see a before/after diff in VS Code's native diff editor +- **Accept** or **Discard** changes per-file via inline buttons +- **Accept All** / **Discard All** via the SCM title bar +- Gutter diff indicators (green/red bars) show exactly what changed + +### Line-Level Decorations + +When the agent modifies a file, you'll see: +- **Green background** on newly added lines +- **Yellow background** on modified lines +- **Left border gutter indicator** on all agent-touched lines +- **Hover** any decorated line to see "Modified by GSD Agent" + +### Checkpoints & Rollback + +Automatic checkpoints are created at the start of each agent turn. Use **Discard All** in the SCM panel to revert all agent changes to their original state, or discard individual files. + +### Activity Feed + +The **Activity** panel shows a real-time log of every tool the agent executes — Read, Write, Edit, Bash, Grep, Glob — with status icons (running/success/error), duration, and click-to-open for file operations. + +### Sessions + +The **Sessions** panel lists all past sessions for the current workspace. Click any session to switch to it. The current session is highlighted green. Sessions persist to disk automatically. + +### Diagnostic Integration + +- **Fix Errors** button in the sidebar reads the active file's diagnostics from the Problems panel and sends them to the agent +- **Fix All Problems** (`Cmd+Shift+P` > GSD: Fix All Problems) collects errors/warnings across the workspace +- Works automatically in chat — mention "fix" or "error" and diagnostics are included + +### Code Lens + +Four inline actions above every function and class (TS/JS/Python/Go/Rust): + +| Action | What it does | +|--------|-------------| +| **Ask GSD** | Explain the function/class | +| **Refactor** | Improve clarity, performance, or structure | +| **Find Bugs** | Review for bugs and edge cases | +| **Tests** | Generate test coverage | + +### Git Integration + +- **Commit Agent Changes** — stages and commits modified files with your message +- **Create Branch** — create a new branch for agent work +- **Show Diff** — view git diff of agent changes + +### Approval Modes + +Control how much autonomy the agent has: + +| Mode | Behavior | +|------|----------| +| **Auto-approve** | Agent runs freely (default) | +| **Ask** | Prompts before file writes and commands | +| **Plan-only** | Read-only — agent can analyze but not modify | + +Change via Settings section or `Cmd+Shift+P` > **GSD: Select Approval Mode**. + +### Agent UI Requests + +When the agent needs input (questions, confirmations, selections), VS Code dialogs appear automatically — no more hanging on `ask_user_questions`. + +### Additional Features + +- **Conversation History** — full message viewer with tool calls, thinking blocks, search, and fork-from-here +- **Slash Command Completion** — type `/` for auto-complete of `/gsd` commands +- **File Decorations** — "G" badge on agent-modified files in the Explorer +- **Bash Terminal** — dedicated terminal for agent shell output +- **Context Window Warning** — notification when context exceeds threshold +- **Progress Notifications** — optional notification with cancel button (off by default) + +--- + +## All Commands + +| Command | Shortcut | Description | +|---------|----------|-------------| +| **GSD: Start Agent** | | Connect to the GSD agent | +| **GSD: Stop Agent** | | Disconnect the agent | +| **GSD: New Session** | `Cmd+Shift+G` `Cmd+Shift+N` | Start a fresh conversation | +| **GSD: Send Message** | `Cmd+Shift+G` `Cmd+Shift+P` | Send a message to the agent | +| **GSD: Abort** | `Cmd+Shift+G` `Cmd+Shift+A` | Interrupt the current operation | +| **GSD: Steer Agent** | `Cmd+Shift+G` `Cmd+Shift+I` | Steering message mid-operation | +| **GSD: Switch Model** | | Pick a model from QuickPick | +| **GSD: Cycle Model** | `Cmd+Shift+G` `Cmd+Shift+M` | Rotate to the next model | +| **GSD: Set Thinking Level** | | Choose off / low / medium / high | +| **GSD: Cycle Thinking** | `Cmd+Shift+G` `Cmd+Shift+T` | Rotate through thinking levels | +| **GSD: Compact Context** | | Trigger context compaction | +| **GSD: Export HTML** | | Save session as HTML | +| **GSD: Session Stats** | | Display token usage and cost | +| **GSD: Run Bash** | | Execute a shell command | +| **GSD: List Commands** | | Browse slash commands | +| **GSD: Set Session Name** | | Rename current session | +| **GSD: Copy Last Response** | | Copy to clipboard | +| **GSD: Switch Session** | | Load a different session | +| **GSD: Show History** | | Open conversation viewer | +| **GSD: Fork Session** | | Fork from a previous message | +| **GSD: Fix Problems in File** | | Send file diagnostics to agent | +| **GSD: Fix All Problems** | | Send workspace errors to agent | +| **GSD: Commit Agent Changes** | | Git commit modified files | +| **GSD: Create Branch** | | Create branch for agent work | +| **GSD: Show Agent Diff** | | View git diff | +| **GSD: Accept All Changes** | | Accept all SCM changes | +| **GSD: Discard All Changes** | | Revert all agent modifications | +| **GSD: Select Approval Mode** | | Choose auto-approve/ask/plan-only | +| **GSD: Cycle Approval Mode** | | Rotate through approval modes | +| **GSD: Code Lens** actions | | Ask, Refactor, Find Bugs, Tests | + +> On Windows/Linux, replace `Cmd` with `Ctrl`. + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `gsd.binaryPath` | `"gsd"` | Path to the GSD binary | +| `gsd.autoStart` | `false` | Start agent on extension activation | +| `gsd.autoCompaction` | `true` | Automatic context compaction | +| `gsd.codeLens` | `true` | Code lens above functions/classes | +| `gsd.showProgressNotifications` | `false` | Progress notification (off — Chat shows progress) | +| `gsd.activityFeedMaxItems` | `100` | Max items in Activity feed | +| `gsd.showContextWarning` | `true` | Warn when context exceeds threshold | +| `gsd.contextWarningThreshold` | `80` | Context % that triggers warning | +| `gsd.approvalMode` | `"auto-approve"` | Agent permission mode | ## How It Works -The extension spawns `gsd --mode rpc` in the background and communicates over JSON-RPC via stdin/stdout. All RPC commands are supported, including streaming events for real-time sidebar updates. +The extension spawns `gsd --mode rpc` and communicates over JSON-RPC via stdin/stdout. Agent events stream in real-time. The change tracker captures file state before modifications for SCM diffs and rollback. UI requests from the agent (questions, confirmations) are handled via VS Code dialogs. ## Links diff --git a/vscode-extension/docs/images/overview.png b/vscode-extension/docs/images/overview.png new file mode 100644 index 000000000..eafd6a1df Binary files /dev/null and b/vscode-extension/docs/images/overview.png differ diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 8ea2de271..2a2088fdf 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -3,7 +3,7 @@ "displayName": "GSD-2", "description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, activity feed, conversation history, code lens, session forking, slash command completion, workflow controls, and 33 commands", "publisher": "FluxLabs", - "version": "0.2.0", + "version": "0.3.0", "icon": "logo.jpg", "license": "MIT", "repository": { @@ -168,6 +168,67 @@ { "command": "gsd.generateTestsSymbol", "title": "GSD: Generate Tests for Symbol" + }, + { + "command": "gsd.acceptAllChanges", + "title": "GSD: Accept All Agent Changes", + "icon": "$(check-all)" + }, + { + "command": "gsd.discardAllChanges", + "title": "GSD: Discard All Agent Changes", + "icon": "$(discard)" + }, + { + "command": "gsd.acceptFileChanges", + "title": "Accept Changes", + "icon": "$(check)" + }, + { + "command": "gsd.discardFileChanges", + "title": "Discard Changes", + "icon": "$(discard)" + }, + { + "command": "gsd.restoreCheckpoint", + "title": "GSD: Restore Checkpoint" + }, + { + "command": "gsd.fixProblemsInFile", + "title": "GSD: Fix Problems in File" + }, + { + "command": "gsd.fixAllProblems", + "title": "GSD: Fix All Problems" + }, + { + "command": "gsd.clearDiagnostics", + "title": "GSD: Clear Agent Diagnostics" + }, + { + "command": "gsd.commitAgentChanges", + "title": "GSD: Commit Agent Changes" + }, + { + "command": "gsd.createAgentBranch", + "title": "GSD: Create Branch for Agent Work" + }, + { + "command": "gsd.showAgentDiff", + "title": "GSD: Show Agent Diff" + }, + { + "command": "gsd.clearPlan", + "title": "GSD: Clear Plan View", + "icon": "$(clear-all)" + }, + { + "command": "gsd.cycleApprovalMode", + "title": "GSD: Cycle Approval Mode" + }, + { + "command": "gsd.selectApprovalMode", + "title": "GSD: Select Approval Mode" } ], "keybindings": [ @@ -240,6 +301,30 @@ "when": "view == gsd-activity", "group": "navigation" } + ], + "scm/title": [ + { + "command": "gsd.acceptAllChanges", + "group": "navigation", + "when": "scmProvider == gsd" + }, + { + "command": "gsd.discardAllChanges", + "group": "navigation", + "when": "scmProvider == gsd" + } + ], + "scm/resourceState/context": [ + { + "command": "gsd.acceptFileChanges", + "group": "inline", + "when": "scmProvider == gsd" + }, + { + "command": "gsd.discardFileChanges", + "group": "inline", + "when": "scmProvider == gsd" + } ] }, "chatParticipants": [ @@ -276,7 +361,7 @@ }, "gsd.showProgressNotifications": { "type": "boolean", - "default": true, + "default": false, "description": "Show progress notification while the agent is working" }, "gsd.activityFeedMaxItems": { @@ -297,6 +382,17 @@ "minimum": 50, "maximum": 95, "description": "Context window usage percentage that triggers a warning" + }, + "gsd.approvalMode": { + "type": "string", + "default": "auto-approve", + "enum": ["auto-approve", "ask", "plan-only"], + "enumDescriptions": [ + "Agent runs freely without prompts", + "Prompt before file changes and commands", + "Read-only mode — agent can analyze but not modify" + ], + "description": "Approval mode for agent actions" } } } diff --git a/vscode-extension/src/change-tracker.ts b/vscode-extension/src/change-tracker.ts new file mode 100644 index 000000000..f10191d65 --- /dev/null +++ b/vscode-extension/src/change-tracker.ts @@ -0,0 +1,295 @@ +import * as vscode from "vscode"; +import * as fs from "node:fs"; +import type { GsdClient, AgentEvent } from "./gsd-client.js"; + +export interface FileSnapshot { + uri: vscode.Uri; + originalContent: string; + timestamp: number; +} + +export interface Checkpoint { + id: number; + label: string; + timestamp: number; + /** Map of file path → original content at checkpoint creation time */ + snapshots: Map; +} + +/** + * Tracks file changes made by the GSD agent. Stores original file content + * before the agent modifies it, enabling diff views, SCM integration, + * and checkpoint/rollback functionality. + */ +export class GsdChangeTracker implements vscode.Disposable { + /** file path → original content (before first agent modification this session) */ + private originals = new Map(); + /** Set of file paths modified in the current agent turn */ + private currentTurnFiles = new Set(); + /** Ordered list of checkpoints */ + private _checkpoints: Checkpoint[] = []; + private nextCheckpointId = 1; + /** toolUseId → file path for in-flight tool executions */ + private pendingTools = new Map(); + /** Whether the current turn has been described in the checkpoint label */ + private turnDescribed = false; + + private readonly _onDidChange = new vscode.EventEmitter(); + /** Fires when the set of tracked files changes. Payload is array of changed file paths. */ + readonly onDidChange = this._onDidChange.event; + + private readonly _onCheckpointChange = new vscode.EventEmitter(); + readonly onCheckpointChange = this._onCheckpointChange.event; + + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + this.disposables.push(this._onDidChange, this._onCheckpointChange); + + this.disposables.push( + client.onEvent((evt) => this.handleEvent(evt)), + client.onConnectionChange((connected) => { + if (!connected) { + this.reset(); + } + }), + ); + } + + /** All file paths that have been modified by the agent */ + get modifiedFiles(): string[] { + return [...this.originals.keys()]; + } + + /** Get the original content of a file (before agent first modified it) */ + getOriginal(filePath: string): string | undefined { + return this.originals.get(filePath); + } + + /** Whether the tracker has any modifications */ + get hasChanges(): boolean { + return this.originals.size > 0; + } + + /** Current checkpoints (newest first) */ + get checkpoints(): readonly Checkpoint[] { + return this._checkpoints; + } + + /** + * Discard agent changes to a single file — restore original content. + * Returns true if the file was restored. + */ + async discardFile(filePath: string): Promise { + const original = this.originals.get(filePath); + if (original === undefined) return false; + + try { + await fs.promises.writeFile(filePath, original, "utf8"); + this.originals.delete(filePath); + this._onDidChange.fire([filePath]); + return true; + } catch { + return false; + } + } + + /** + * Discard all agent changes — restore all files to their original state. + */ + async discardAll(): Promise { + let count = 0; + const paths = [...this.originals.keys()]; + for (const filePath of paths) { + if (await this.discardFile(filePath)) { + count++; + } + } + return count; + } + + /** + * Accept changes to a file — remove from tracking (keep the current content). + */ + acceptFile(filePath: string): void { + if (this.originals.delete(filePath)) { + this._onDidChange.fire([filePath]); + } + } + + /** + * Accept all changes — clear all tracking. + */ + acceptAll(): void { + const paths = [...this.originals.keys()]; + this.originals.clear(); + if (paths.length > 0) { + this._onDidChange.fire(paths); + } + } + + /** + * Restore all files to a checkpoint state. + */ + async restoreCheckpoint(checkpointId: number): Promise { + const idx = this._checkpoints.findIndex((c) => c.id === checkpointId); + if (idx === -1) return 0; + + const checkpoint = this._checkpoints[idx]; + let count = 0; + + for (const [filePath, content] of checkpoint.snapshots) { + try { + await fs.promises.writeFile(filePath, content, "utf8"); + count++; + } catch { + // skip files that can't be restored + } + } + + // Reset originals to the checkpoint state + this.originals = new Map(checkpoint.snapshots); + + // Remove all checkpoints after this one + this._checkpoints = this._checkpoints.slice(0, idx); + + this._onDidChange.fire([...checkpoint.snapshots.keys()]); + this._onCheckpointChange.fire(); + return count; + } + + /** Clear all tracking state */ + reset(): void { + const paths = [...this.originals.keys()]; + this.originals.clear(); + this.currentTurnFiles.clear(); + this.pendingTools.clear(); + this._checkpoints = []; + this.nextCheckpointId = 1; + if (paths.length > 0) { + this._onDidChange.fire(paths); + } + this._onCheckpointChange.fire(); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } + + private handleEvent(evt: AgentEvent): void { + switch (evt.type) { + case "agent_start": + this.createCheckpoint(); + this.currentTurnFiles.clear(); + this.turnDescribed = false; + break; + + case "tool_execution_start": { + const toolName = String(evt.toolName ?? ""); + const toolInput = (evt.toolInput ?? {}) as Record; + const toolUseId = String(evt.toolUseId ?? ""); + + // Update checkpoint label with first action description + if (!this.turnDescribed) { + this.turnDescribed = true; + this.updateLatestCheckpointLabel(describeAction(toolName, toolInput)); + } + + if (toolName !== "Write" && toolName !== "Edit") break; + + const filePath = String(toolInput.file_path ?? toolInput.path ?? ""); + + if (!filePath) break; + + // Store the original content before the agent modifies it + // Only capture on FIRST modification (don't overwrite) + if (!this.originals.has(filePath)) { + try { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, "utf8"); + this.originals.set(filePath, content); + } else { + // File doesn't exist yet — original is "empty" (new file) + this.originals.set(filePath, ""); + } + } catch { + // Can't read file, skip tracking + } + } + + if (toolUseId) { + this.pendingTools.set(toolUseId, filePath); + } + break; + } + + case "tool_execution_end": { + const toolUseId = String(evt.toolUseId ?? ""); + const filePath = this.pendingTools.get(toolUseId); + if (filePath) { + this.pendingTools.delete(toolUseId); + this.currentTurnFiles.add(filePath); + this._onDidChange.fire([filePath]); + } + break; + } + } + } + + private createCheckpoint(): void { + const now = Date.now(); + const time = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const fileCount = this.originals.size; + const label = fileCount > 0 + ? `${time} (${fileCount} file${fileCount !== 1 ? "s" : ""} tracked)` + : `${time} (start)`; + + const checkpoint: Checkpoint = { + id: this.nextCheckpointId++, + label, + timestamp: now, + snapshots: new Map(this.originals), + }; + this._checkpoints.push(checkpoint); + this._onCheckpointChange.fire(); + } + + /** + * Update the label of the latest checkpoint with a description + * of the first action taken (called after first tool execution in a turn). + */ + private updateLatestCheckpointLabel(description: string): void { + if (this._checkpoints.length === 0) return; + const latest = this._checkpoints[this._checkpoints.length - 1]; + const time = new Date(latest.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + latest.label = `${time} — ${description}`; + this._onCheckpointChange.fire(); + } +} + +function describeAction(toolName: string, input: Record): string { + switch (toolName) { + case "Read": { + const p = String(input.file_path ?? input.path ?? ""); + return `Read ${p.split(/[\\/]/).pop() ?? p}`; + } + case "Write": { + const p = String(input.file_path ?? ""); + return `Write ${p.split(/[\\/]/).pop() ?? p}`; + } + case "Edit": { + const p = String(input.file_path ?? ""); + return `Edit ${p.split(/[\\/]/).pop() ?? p}`; + } + case "Bash": + return `$ ${String(input.command ?? "").slice(0, 40)}`; + case "Grep": + return `Grep: ${String(input.pattern ?? "").slice(0, 30)}`; + case "Glob": + return `Glob: ${String(input.pattern ?? "").slice(0, 30)}`; + default: + return toolName; + } +} diff --git a/vscode-extension/src/chat-participant.ts b/vscode-extension/src/chat-participant.ts index 01647e1ad..6ba3e60e2 100644 --- a/vscode-extension/src/chat-participant.ts +++ b/vscode-extension/src/chat-participant.ts @@ -39,6 +39,21 @@ export function registerChatParticipant( message = `${fileContext}\n\n${message}`; } + // Auto-include editor selection if present and not already referenced + const selectionContext = getSelectionContext(); + if (selectionContext) { + message = `${selectionContext}\n\n${message}`; + } + + // Auto-include diagnostics for the active file if the prompt mentions "fix", "error", "problem", "warning" + const fixKeywords = /\b(fix|error|problem|warning|issue|bug|lint|diagnos)/i; + if (fixKeywords.test(message)) { + const diagContext = getActiveDiagnosticsContext(); + if (diagContext) { + message = `${message}\n\n${diagContext}`; + } + } + // Track streaming state let agentDone = false; let totalInputTokens = 0; @@ -281,3 +296,42 @@ function resolveFileUri(fp: string): vscode.Uri | null { return null; } } + +/** + * Get the current editor selection as context, if any text is selected. + */ +function getSelectionContext(): string | null { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.selection.isEmpty) return null; + + const selection = editor.document.getText(editor.selection); + if (!selection.trim()) return null; + + const relativePath = vscode.workspace.asRelativePath(editor.document.uri); + const { start, end } = editor.selection; + return `Selected code in \`${relativePath}\` (lines ${start.line + 1}-${end.line + 1}):\n\`\`\`\n${selection}\n\`\`\``; +} + +/** + * Get diagnostics (errors/warnings) for the active editor file. + */ +function getActiveDiagnosticsContext(): string | null { + const editor = vscode.window.activeTextEditor; + if (!editor) return null; + + const diagnostics = vscode.languages.getDiagnostics(editor.document.uri); + const significant = diagnostics.filter( + (d) => d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning, + ); + if (significant.length === 0) return null; + + const relativePath = vscode.workspace.asRelativePath(editor.document.uri); + const lines = [`Current diagnostics in \`${relativePath}\`:`]; + for (const d of significant) { + const sev = d.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning"; + const line = d.range.start.line + 1; + const source = d.source ? ` [${d.source}]` : ""; + lines.push(`- ${sev} (line ${line}): ${d.message}${source}`); + } + return lines.join("\n"); +} diff --git a/vscode-extension/src/checkpoints.ts b/vscode-extension/src/checkpoints.ts new file mode 100644 index 000000000..584c9011c --- /dev/null +++ b/vscode-extension/src/checkpoints.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode"; +import type { GsdChangeTracker, Checkpoint } from "./change-tracker.js"; + +/** + * TreeDataProvider that shows agent checkpoints (one per agent turn). + * Each checkpoint can be restored to revert all file changes since that point. + */ +export class GsdCheckpointProvider implements vscode.TreeDataProvider, vscode.Disposable { + public static readonly viewId = "gsd-checkpoints"; + + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private disposables: vscode.Disposable[] = []; + + constructor(private readonly tracker: GsdChangeTracker) { + this.disposables.push( + this._onDidChangeTreeData, + tracker.onCheckpointChange(() => this._onDidChangeTreeData.fire()), + ); + } + + getTreeItem(checkpoint: Checkpoint): vscode.TreeItem { + const fileCount = checkpoint.snapshots.size; + const time = new Date(checkpoint.timestamp); + const timeStr = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + + const item = new vscode.TreeItem( + checkpoint.label, + vscode.TreeItemCollapsibleState.None, + ); + item.description = `${timeStr} (${fileCount} file${fileCount !== 1 ? "s" : ""})`; + item.iconPath = new vscode.ThemeIcon("history"); + item.tooltip = `Checkpoint: ${checkpoint.label}\nTime: ${time.toLocaleString()}\nFiles tracked: ${fileCount}\n\nClick to restore to this point`; + item.contextValue = "checkpoint"; + item.command = { + command: "gsd.restoreCheckpoint", + title: "Restore Checkpoint", + arguments: [checkpoint.id], + }; + + return item; + } + + getChildren(): Checkpoint[] { + // Show newest first + return [...this.tracker.checkpoints].reverse(); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } +} diff --git a/vscode-extension/src/diagnostics.ts b/vscode-extension/src/diagnostics.ts new file mode 100644 index 000000000..cd25ccfee --- /dev/null +++ b/vscode-extension/src/diagnostics.ts @@ -0,0 +1,142 @@ +import * as vscode from "vscode"; +import type { GsdClient } from "./gsd-client.js"; + +/** + * Integrates with VS Code's diagnostic system: + * - Reads diagnostics (errors/warnings) from the Problems panel and sends them to the agent + * - Provides a DiagnosticCollection for the agent to surface its own findings + */ +export class GsdDiagnosticBridge implements vscode.Disposable { + private readonly collection: vscode.DiagnosticCollection; + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + this.collection = vscode.languages.createDiagnosticCollection("gsd"); + this.disposables.push(this.collection); + } + + /** + * Read all diagnostics for the active file and send them to the agent + * as a "fix these problems" prompt. + */ + async fixProblemsInFile(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("No active file to fix."); + return; + } + + const uri = editor.document.uri; + const diagnostics = vscode.languages.getDiagnostics(uri); + + if (diagnostics.length === 0) { + vscode.window.showInformationMessage("No problems found in this file."); + return; + } + + const fileName = vscode.workspace.asRelativePath(uri); + const problemText = formatDiagnostics(fileName, diagnostics); + + const prompt = [ + `Fix the following problems in \`${fileName}\`:`, + "", + problemText, + "", + "Fix all of these issues. Show me the changes.", + ].join("\n"); + + await this.client.sendPrompt(prompt); + } + + /** + * Read all diagnostics across the workspace (errors only) and send + * them to the agent as a "fix all errors" prompt. + */ + async fixAllProblems(): Promise { + const allDiagnostics = vscode.languages.getDiagnostics(); + const errorFiles: { fileName: string; diagnostics: vscode.Diagnostic[] }[] = []; + + for (const [uri, diagnostics] of allDiagnostics) { + // Only include errors and warnings, skip hints/info + const significant = diagnostics.filter( + (d) => d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning, + ); + if (significant.length > 0) { + errorFiles.push({ + fileName: vscode.workspace.asRelativePath(uri), + diagnostics: significant, + }); + } + } + + if (errorFiles.length === 0) { + vscode.window.showInformationMessage("No errors or warnings found in the workspace."); + return; + } + + // Cap at 20 files to avoid overwhelming the agent + const capped = errorFiles.slice(0, 20); + const totalProblems = capped.reduce((sum, f) => sum + f.diagnostics.length, 0); + + const sections = capped.map((f) => formatDiagnostics(f.fileName, f.diagnostics)); + + const prompt = [ + `Fix the following ${totalProblems} problems across ${capped.length} file${capped.length > 1 ? "s" : ""}:`, + "", + ...sections, + "", + "Fix all of these issues.", + ].join("\n"); + + await this.client.sendPrompt(prompt); + } + + /** + * Add a GSD diagnostic (agent finding) to a file. + * Can be used to surface agent review findings in the Problems panel. + */ + addFinding( + uri: vscode.Uri, + range: vscode.Range, + message: string, + severity: vscode.DiagnosticSeverity = vscode.DiagnosticSeverity.Warning, + ): void { + const existing = this.collection.get(uri) ?? []; + const diagnostic = new vscode.Diagnostic(range, message, severity); + diagnostic.source = "GSD Agent"; + this.collection.set(uri, [...existing, diagnostic]); + } + + /** Clear all GSD diagnostics */ + clearFindings(): void { + this.collection.clear(); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } +} + +function formatDiagnostics(fileName: string, diagnostics: vscode.Diagnostic[]): string { + const lines = [`**${fileName}**`]; + for (const d of diagnostics) { + const severity = severityLabel(d.severity); + const line = d.range.start.line + 1; + const col = d.range.start.character + 1; + const source = d.source ? ` [${d.source}]` : ""; + lines.push(` - ${severity} (line ${line}:${col}): ${d.message}${source}`); + } + return lines.join("\n"); +} + +function severityLabel(severity: vscode.DiagnosticSeverity): string { + switch (severity) { + case vscode.DiagnosticSeverity.Error: return "Error"; + case vscode.DiagnosticSeverity.Warning: return "Warning"; + case vscode.DiagnosticSeverity.Information: return "Info"; + case vscode.DiagnosticSeverity.Hint: return "Hint"; + default: return "Unknown"; + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index d909c4e12..f5e494240 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -9,12 +9,24 @@ import { GsdConversationHistoryPanel } from "./conversation-history.js"; import { GsdSlashCompletionProvider } from "./slash-completion.js"; import { GsdCodeLensProvider } from "./code-lens.js"; import { GsdActivityFeedProvider } from "./activity-feed.js"; +import { GsdChangeTracker } from "./change-tracker.js"; +import { GsdScmProvider } from "./scm-provider.js"; +import { GsdDiagnosticBridge } from "./diagnostics.js"; +import { GsdLineDecorationManager } from "./line-decorations.js"; +import { GsdGitIntegration } from "./git-integration.js"; +import { GsdPermissionManager } from "./permissions.js"; let client: GsdClient | undefined; let sidebarProvider: GsdSidebarProvider | undefined; let fileDecorations: GsdFileDecorationProvider | undefined; let sessionTreeProvider: GsdSessionTreeProvider | undefined; let activityFeedProvider: GsdActivityFeedProvider | undefined; +let changeTracker: GsdChangeTracker | undefined; +let scmProvider: GsdScmProvider | undefined; +let diagnosticBridge: GsdDiagnosticBridge | undefined; +let lineDecorations: GsdLineDecorationManager | undefined; +let gitIntegration: GsdGitIntegration | undefined; +let permissionManager: GsdPermissionManager | undefined; function requireConnected(): boolean { if (!client?.isConnected) { @@ -128,6 +140,34 @@ export function activate(context: vscode.ExtensionContext): void { vscode.window.registerTreeDataProvider(GsdActivityFeedProvider.viewId, activityFeedProvider), ); + // -- Change tracker & SCM provider ------------------------------------- + + changeTracker = new GsdChangeTracker(client); + context.subscriptions.push(changeTracker); + + scmProvider = new GsdScmProvider(changeTracker, cwd); + context.subscriptions.push(scmProvider); + + // -- Diagnostics ------------------------------------------------------- + + diagnosticBridge = new GsdDiagnosticBridge(client); + context.subscriptions.push(diagnosticBridge); + + // -- Line-level decorations -------------------------------------------- + + lineDecorations = new GsdLineDecorationManager(changeTracker!); + context.subscriptions.push(lineDecorations); + + // -- Git integration --------------------------------------------------- + + gitIntegration = new GsdGitIntegration(changeTracker!, cwd); + context.subscriptions.push(gitIntegration); + + // -- Permissions ------------------------------------------------------- + + permissionManager = new GsdPermissionManager(client); + context.subscriptions.push(permissionManager); + // -- Progress notifications -------------------------------------------- let currentProgress: { resolve: () => void } | undefined; @@ -789,6 +829,135 @@ export function activate(context: vscode.ExtensionContext): void { }), ); + // -- SCM commands ------------------------------------------------------- + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.acceptAllChanges", () => { + changeTracker?.acceptAll(); + vscode.window.showInformationMessage("All agent changes accepted."); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.discardAllChanges", async () => { + if (!changeTracker?.hasChanges) { + vscode.window.showInformationMessage("No agent changes to discard."); + return; + } + const confirm = await vscode.window.showWarningMessage( + `Discard all agent changes (${changeTracker.modifiedFiles.length} files)?`, + { modal: true }, + "Discard", + ); + if (confirm === "Discard") { + const count = await changeTracker.discardAll(); + vscode.window.showInformationMessage(`Reverted ${count} file${count !== 1 ? "s" : ""}.`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.discardFileChanges", async (resourceState: vscode.SourceControlResourceState) => { + if (!changeTracker || !resourceState?.resourceUri) return; + const filePath = resourceState.resourceUri.fsPath; + const success = await changeTracker.discardFile(filePath); + if (success) { + vscode.window.showInformationMessage(`Reverted ${vscode.workspace.asRelativePath(filePath)}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.acceptFileChanges", (resourceState: vscode.SourceControlResourceState) => { + if (!changeTracker || !resourceState?.resourceUri) return; + changeTracker.acceptFile(resourceState.resourceUri.fsPath); + }), + ); + + // -- Checkpoint commands ------------------------------------------------ + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.restoreCheckpoint", async (checkpointId: number) => { + if (!changeTracker) return; + const checkpoint = changeTracker.checkpoints.find((c) => c.id === checkpointId); + if (!checkpoint) return; + + const confirm = await vscode.window.showWarningMessage( + `Restore to "${checkpoint.label}"? This will revert files to their state at ${new Date(checkpoint.timestamp).toLocaleTimeString()}.`, + { modal: true }, + "Restore", + ); + if (confirm === "Restore") { + const count = await changeTracker.restoreCheckpoint(checkpointId); + vscode.window.showInformationMessage(`Restored ${count} file${count !== 1 ? "s" : ""} to checkpoint.`); + } + }), + ); + + // -- Diagnostic commands ------------------------------------------------ + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.fixProblemsInFile", async () => { + if (!requireConnected()) return; + try { + await diagnosticBridge!.fixProblemsInFile(); + } catch (err) { + handleError(err, "Failed to fix problems"); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.fixAllProblems", async () => { + if (!requireConnected()) return; + try { + await diagnosticBridge!.fixAllProblems(); + } catch (err) { + handleError(err, "Failed to fix problems"); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.clearDiagnostics", () => { + diagnosticBridge?.clearFindings(); + }), + ); + + // -- Permission commands ------------------------------------------------ + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.cycleApprovalMode", () => { + permissionManager?.cycleMode(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.selectApprovalMode", () => { + permissionManager?.selectMode(); + }), + ); + + // -- Git commands ------------------------------------------------------- + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.commitAgentChanges", () => { + gitIntegration?.commitAgentChanges(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.createAgentBranch", () => { + gitIntegration?.createAgentBranch(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gsd.showAgentDiff", () => { + gitIntegration?.showAgentDiff(); + }), + ); + // -- Auto-start --------------------------------------------------------- if (config.get("autoStart", false)) { @@ -802,9 +971,21 @@ export function deactivate(): void { fileDecorations?.dispose(); sessionTreeProvider?.dispose(); activityFeedProvider?.dispose(); + changeTracker?.dispose(); + scmProvider?.dispose(); + diagnosticBridge?.dispose(); + lineDecorations?.dispose(); + gitIntegration?.dispose(); + permissionManager?.dispose(); client = undefined; sidebarProvider = undefined; fileDecorations = undefined; sessionTreeProvider = undefined; activityFeedProvider = undefined; + changeTracker = undefined; + scmProvider = undefined; + diagnosticBridge = undefined; + lineDecorations = undefined; + gitIntegration = undefined; + permissionManager = undefined; } diff --git a/vscode-extension/src/git-integration.ts b/vscode-extension/src/git-integration.ts new file mode 100644 index 000000000..dbec79dba --- /dev/null +++ b/vscode-extension/src/git-integration.ts @@ -0,0 +1,122 @@ +import * as vscode from "vscode"; +import { exec } from "node:child_process"; +import type { GsdChangeTracker } from "./change-tracker.js"; + +/** + * Provides git integration for agent changes — commit, branch, and diff. + */ +export class GsdGitIntegration implements vscode.Disposable { + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly tracker: GsdChangeTracker, + private readonly cwd: string, + ) {} + + /** + * Commit all files modified by the agent with a user-provided message. + */ + async commitAgentChanges(): Promise { + const files = this.tracker.modifiedFiles; + if (files.length === 0) { + vscode.window.showInformationMessage("No agent changes to commit."); + return; + } + + const defaultMsg = `feat: agent changes (${files.length} file${files.length !== 1 ? "s" : ""})`; + const message = await vscode.window.showInputBox({ + prompt: "Commit message for agent changes", + value: defaultMsg, + placeHolder: "feat: describe the changes", + }); + if (!message) return; + + try { + // Stage the modified files + await this.git(`add ${files.map((f) => `"${f}"`).join(" ")}`); + // Commit + await this.git(`commit -m "${message.replace(/"/g, '\\"')}"`); + + // Accept all changes (clear tracking since they're committed) + this.tracker.acceptAll(); + + vscode.window.showInformationMessage(`Committed ${files.length} file${files.length !== 1 ? "s" : ""}.`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Git commit failed: ${msg}`); + } + } + + /** + * Create a new branch for agent work and switch to it. + */ + async createAgentBranch(): Promise { + const branchName = await vscode.window.showInputBox({ + prompt: "Branch name for agent work", + placeHolder: "feat/agent-changes", + validateInput: (value) => { + if (!value.trim()) return "Branch name is required"; + if (/\s/.test(value)) return "Branch name cannot contain spaces"; + return null; + }, + }); + if (!branchName) return; + + try { + await this.git(`checkout -b "${branchName}"`); + vscode.window.showInformationMessage(`Created and switched to branch: ${branchName}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to create branch: ${msg}`); + } + } + + /** + * Show a git diff of all agent-modified files. + */ + async showAgentDiff(): Promise { + const files = this.tracker.modifiedFiles; + if (files.length === 0) { + vscode.window.showInformationMessage("No agent changes to diff."); + return; + } + + try { + const diff = await this.git("diff"); + if (!diff.trim()) { + // Files may be untracked — show status instead + const status = await this.git("status --short"); + const channel = vscode.window.createOutputChannel("GSD Git Diff"); + channel.appendLine("# Agent-modified files (unstaged):"); + channel.appendLine(status); + channel.show(); + } else { + const channel = vscode.window.createOutputChannel("GSD Git Diff"); + channel.clear(); + channel.appendLine(diff); + channel.show(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Git diff failed: ${msg}`); + } + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } + + private git(args: string): Promise { + return new Promise((resolve, reject) => { + exec(`git ${args}`, { cwd: this.cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) { + reject(new Error(stderr.trim() || err.message)); + } else { + resolve(stdout); + } + }); + }); + } +} diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts index b8ae2bc35..b2a872c5e 100644 --- a/vscode-extension/src/gsd-client.ts +++ b/vscode-extension/src/gsd-client.ts @@ -123,11 +123,10 @@ export class GsdClient implements vscode.Disposable { return; } - const proc = spawn(this.binaryPath, ["--mode", "rpc", "--no-session"], { + const proc = spawn(this.binaryPath, ["--mode", "rpc"], { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env }, - shell: process.platform === "win32", }); this.process = proc; @@ -580,10 +579,104 @@ export class GsdClient implements vscode.Disposable { return; } + // Extension UI request — agent needs user input + if (data.type === "extension_ui_request" && typeof data.id === "string") { + void this.handleUIRequest(data); + return; + } + // Streaming event this._onEvent.fire(data as AgentEvent); } + private async handleUIRequest(request: Record): Promise { + const id = request.id as string; + const method = request.method as string; + + try { + switch (method) { + case "select": { + const options = (request.options as string[]) ?? []; + const title = String(request.title ?? "Select"); + const allowMultiple = request.allowMultiple === true; + + if (allowMultiple) { + const picked = await vscode.window.showQuickPick(options, { + title, + canPickMany: true, + }); + if (picked) { + this.sendRaw({ type: "extension_ui_response", id, values: picked }); + } else { + this.sendRaw({ type: "extension_ui_response", id, cancelled: true }); + } + } else { + const picked = await vscode.window.showQuickPick(options, { title }); + if (picked) { + this.sendRaw({ type: "extension_ui_response", id, value: picked }); + } else { + this.sendRaw({ type: "extension_ui_response", id, cancelled: true }); + } + } + break; + } + + case "confirm": { + const title = String(request.title ?? "Confirm"); + const message = String(request.message ?? ""); + const result = await vscode.window.showInformationMessage( + `${title}: ${message}`, + { modal: true }, + "Yes", + "No", + ); + this.sendRaw({ type: "extension_ui_response", id, confirmed: result === "Yes" }); + break; + } + + case "input": { + const title = String(request.title ?? "Input"); + const placeholder = String(request.placeholder ?? ""); + const value = await vscode.window.showInputBox({ title, placeHolder: placeholder }); + if (value !== undefined) { + this.sendRaw({ type: "extension_ui_response", id, value }); + } else { + this.sendRaw({ type: "extension_ui_response", id, cancelled: true }); + } + break; + } + + case "notify": { + const message = String(request.message ?? ""); + const notifyType = String(request.notifyType ?? "info"); + if (notifyType === "error") { + vscode.window.showErrorMessage(`GSD: ${message}`); + } else if (notifyType === "warning") { + vscode.window.showWarningMessage(`GSD: ${message}`); + } else { + vscode.window.showInformationMessage(`GSD: ${message}`); + } + // Notify doesn't need a response + break; + } + + default: + // Unknown method — cancel to unblock the agent + this.sendRaw({ type: "extension_ui_response", id, cancelled: true }); + break; + } + } catch { + // On error, cancel to unblock + this.sendRaw({ type: "extension_ui_response", id, cancelled: true }); + } + } + + private sendRaw(data: Record): void { + if (this.process?.stdin) { + this.process.stdin.write(JSON.stringify(data) + "\n"); + } + } + private send(command: Record): Promise { if (!this.process?.stdin) { return Promise.reject(new Error("GSD client not started")); diff --git a/vscode-extension/src/line-decorations.ts b/vscode-extension/src/line-decorations.ts new file mode 100644 index 000000000..387986f79 --- /dev/null +++ b/vscode-extension/src/line-decorations.ts @@ -0,0 +1,130 @@ +import * as vscode from "vscode"; +import type { GsdChangeTracker } from "./change-tracker.js"; + +/** + * Provides line-level editor decorations for files modified by the GSD agent. + * Shows subtle background highlights on changed lines and gutter icons. + */ +export class GsdLineDecorationManager implements vscode.Disposable { + private readonly addedDecoration: vscode.TextEditorDecorationType; + private readonly modifiedDecoration: vscode.TextEditorDecorationType; + private readonly gutterDecoration: vscode.TextEditorDecorationType; + private disposables: vscode.Disposable[] = []; + + constructor(private readonly tracker: GsdChangeTracker) { + this.addedDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: "rgba(78, 201, 176, 0.07)", + overviewRulerColor: "rgba(78, 201, 176, 0.5)", + overviewRulerLane: vscode.OverviewRulerLane.Left, + }); + + this.modifiedDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: "rgba(204, 167, 0, 0.07)", + overviewRulerColor: "rgba(204, 167, 0, 0.5)", + overviewRulerLane: vscode.OverviewRulerLane.Left, + }); + + this.gutterDecoration = vscode.window.createTextEditorDecorationType({ + gutterIconPath: new vscode.ThemeIcon("hubot").id, // fallback + gutterIconSize: "contain", + // Use a colored left border as a gutter indicator (more reliable than icons) + borderWidth: "0 0 0 3px", + borderStyle: "solid", + borderColor: "rgba(78, 201, 176, 0.4)", + }); + + this.disposables.push( + this.addedDecoration, + this.modifiedDecoration, + this.gutterDecoration, + ); + + // Refresh decorations when tracked files change + this.disposables.push( + tracker.onDidChange(() => this.refreshAll()), + vscode.window.onDidChangeActiveTextEditor(() => this.refreshAll()), + vscode.workspace.onDidChangeTextDocument((e) => { + const editor = vscode.window.activeTextEditor; + if (editor && e.document === editor.document) { + this.refreshEditor(editor); + } + }), + ); + } + + private refreshAll(): void { + for (const editor of vscode.window.visibleTextEditors) { + this.refreshEditor(editor); + } + } + + private refreshEditor(editor: vscode.TextEditor): void { + const filePath = editor.document.uri.fsPath; + const original = this.tracker.getOriginal(filePath); + + if (original === undefined) { + // No tracked changes for this file — clear decorations + editor.setDecorations(this.addedDecoration, []); + editor.setDecorations(this.modifiedDecoration, []); + editor.setDecorations(this.gutterDecoration, []); + return; + } + + const currentLines = editor.document.getText().split("\n"); + const originalLines = original.split("\n"); + const { added, modified } = diffLines(originalLines, currentLines); + + const addedRanges = added.map((line) => { + const range = new vscode.Range(line, 0, line, currentLines[line]?.length ?? 0); + return { range, hoverMessage: new vscode.MarkdownString("$(hubot) *Added by GSD Agent*") }; + }); + + const modifiedRanges = modified.map((line) => { + const range = new vscode.Range(line, 0, line, currentLines[line]?.length ?? 0); + return { range, hoverMessage: new vscode.MarkdownString("$(hubot) *Modified by GSD Agent*") }; + }); + + const gutterRanges = [...added, ...modified].map((line) => ({ + range: new vscode.Range(line, 0, line, 0), + })); + + editor.setDecorations(this.addedDecoration, addedRanges); + editor.setDecorations(this.modifiedDecoration, modifiedRanges); + editor.setDecorations(this.gutterDecoration, gutterRanges); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } +} + +/** + * Simple line-level diff: compare original vs current line-by-line. + * Returns arrays of line numbers that were added or modified. + */ +function diffLines( + originalLines: string[], + currentLines: string[], +): { added: number[]; modified: number[] } { + const added: number[] = []; + const modified: number[] = []; + + const maxShared = Math.min(originalLines.length, currentLines.length); + + for (let i = 0; i < maxShared; i++) { + if (originalLines[i] !== currentLines[i]) { + modified.push(i); + } + } + + // Lines beyond original length are "added" + for (let i = originalLines.length; i < currentLines.length; i++) { + added.push(i); + } + + return { added, modified }; +} diff --git a/vscode-extension/src/permissions.ts b/vscode-extension/src/permissions.ts new file mode 100644 index 000000000..32bcc9511 --- /dev/null +++ b/vscode-extension/src/permissions.ts @@ -0,0 +1,143 @@ +import * as vscode from "vscode"; +import type { GsdClient, AgentEvent } from "./gsd-client.js"; + +type ApprovalMode = "ask" | "auto-approve" | "plan-only"; + +/** + * Permission/approval system for agent actions. + * Can be configured to prompt before file writes, command execution, etc. + */ +export class GsdPermissionManager implements vscode.Disposable { + private _mode: ApprovalMode = "auto-approve"; + private disposables: vscode.Disposable[] = []; + + private readonly _onModeChange = new vscode.EventEmitter(); + readonly onModeChange = this._onModeChange.event; + + constructor(private readonly client: GsdClient) { + // Load saved mode from configuration + this._mode = vscode.workspace.getConfiguration("gsd").get("approvalMode", "auto-approve"); + + this.disposables.push( + this._onModeChange, + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("gsd.approvalMode")) { + this._mode = vscode.workspace.getConfiguration("gsd").get("approvalMode", "auto-approve"); + this._onModeChange.fire(this._mode); + } + }), + ); + + // If mode is "ask", intercept tool executions for write operations + if (this._mode === "ask") { + this.disposables.push( + client.onEvent((evt) => this.handleEvent(evt)), + ); + } + } + + get mode(): ApprovalMode { + return this._mode; + } + + /** + * Cycle through approval modes: auto-approve -> ask -> plan-only -> auto-approve + */ + async cycleMode(): Promise { + const modes: ApprovalMode[] = ["auto-approve", "ask", "plan-only"]; + const currentIdx = modes.indexOf(this._mode); + this._mode = modes[(currentIdx + 1) % modes.length]; + + await vscode.workspace.getConfiguration("gsd").update("approvalMode", this._mode, vscode.ConfigurationTarget.Workspace); + this._onModeChange.fire(this._mode); + + const labels: Record = { + "auto-approve": "Auto-Approve (agent runs freely)", + "ask": "Ask (prompt before file changes)", + "plan-only": "Plan Only (read-only, no writes)", + }; + vscode.window.showInformationMessage(`Approval mode: ${labels[this._mode]}`); + } + + /** + * Show a QuickPick to select approval mode. + */ + async selectMode(): Promise { + const items: (vscode.QuickPickItem & { mode: ApprovalMode })[] = [ + { + label: "$(check) Auto-Approve", + description: "Agent runs freely without prompts", + detail: "Best for trusted workflows. The agent can read, write, and execute without asking.", + mode: "auto-approve", + }, + { + label: "$(shield) Ask", + description: "Prompt before file changes", + detail: "The agent will ask for approval before writing or editing files.", + mode: "ask", + }, + { + label: "$(eye) Plan Only", + description: "Read-only mode, no writes allowed", + detail: "The agent can read and analyze but cannot modify files or run commands.", + mode: "plan-only", + }, + ]; + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: `Current mode: ${this._mode}`, + }); + + if (selected) { + this._mode = selected.mode; + await vscode.workspace.getConfiguration("gsd").update("approvalMode", this._mode, vscode.ConfigurationTarget.Workspace); + this._onModeChange.fire(this._mode); + } + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } + + private async handleEvent(evt: AgentEvent): Promise { + if (this._mode !== "ask") return; + if (evt.type !== "tool_execution_start") return; + + const toolName = String(evt.toolName ?? ""); + if (toolName !== "Write" && toolName !== "Edit" && toolName !== "Bash") return; + + const toolInput = (evt.toolInput ?? {}) as Record; + let description = ""; + + switch (toolName) { + case "Write": + case "Edit": { + const filePath = String(toolInput.file_path ?? ""); + const shortPath = filePath.split(/[\\/]/).slice(-3).join("/"); + description = `${toolName}: ${shortPath}`; + break; + } + case "Bash": { + const cmd = String(toolInput.command ?? "").slice(0, 80); + description = `Execute: ${cmd}`; + break; + } + } + + // Note: In practice, the RPC protocol doesn't support blocking tool execution + // for approval. This notification serves as awareness — the user sees what's + // happening and can abort if needed. True blocking approval would require + // protocol changes in the RPC server. + vscode.window.showInformationMessage( + `Agent: ${description}`, + "OK", + "Abort", + ).then((choice) => { + if (choice === "Abort") { + this.client.abort().catch(() => {}); + } + }); + } +} diff --git a/vscode-extension/src/plan-viewer.ts b/vscode-extension/src/plan-viewer.ts new file mode 100644 index 000000000..a45b20978 --- /dev/null +++ b/vscode-extension/src/plan-viewer.ts @@ -0,0 +1,190 @@ +import * as vscode from "vscode"; +import type { GsdClient, AgentEvent } from "./gsd-client.js"; + +interface PlanStep { + id: number; + tool: string; + description: string; + status: "pending" | "running" | "done" | "error"; + timestamp: number; + duration?: number; +} + +/** + * TreeDataProvider that shows a plan-like view of agent tool executions. + * Displays steps as they happen, showing what the agent is doing and + * what it has completed — a live execution plan. + */ +export class GsdPlanViewerProvider implements vscode.TreeDataProvider, vscode.Disposable { + public static readonly viewId = "gsd-plan"; + + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private steps: PlanStep[] = []; + private nextId = 0; + private runningTools = new Map(); // toolUseId -> step id + private disposables: vscode.Disposable[] = []; + + constructor(private readonly client: GsdClient) { + this.disposables.push( + this._onDidChangeTreeData, + client.onEvent((evt) => this.handleEvent(evt)), + client.onConnectionChange((connected) => { + if (!connected) { + this.steps = []; + this.runningTools.clear(); + this._onDidChangeTreeData.fire(); + } + }), + ); + } + + getTreeItem(step: PlanStep): vscode.TreeItem { + const icon = stepIcon(step.status); + const item = new vscode.TreeItem(step.description, vscode.TreeItemCollapsibleState.None); + item.iconPath = icon; + item.description = step.duration !== undefined ? `${step.duration}ms` : step.status === "running" ? "running..." : ""; + + const time = new Date(step.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + item.tooltip = `${step.tool}: ${step.description}\nStatus: ${step.status}\nTime: ${time}`; + + return item; + } + + getChildren(): PlanStep[] { + return this.steps; + } + + clear(): void { + this.steps = []; + this.runningTools.clear(); + this._onDidChangeTreeData.fire(); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } + + private handleEvent(evt: AgentEvent): void { + switch (evt.type) { + case "agent_start": { + // Don't clear — keep history visible. Add a separator. + if (this.steps.length > 0) { + this.steps.push({ + id: this.nextId++, + tool: "separator", + description: "--- New Turn ---", + status: "done", + timestamp: Date.now(), + }); + } + this.steps.push({ + id: this.nextId++, + tool: "agent", + description: "Agent started", + status: "running", + timestamp: Date.now(), + }); + this._onDidChangeTreeData.fire(); + break; + } + + case "agent_end": { + // Mark the agent step as done + const agentStep = [...this.steps].reverse().find((s) => s.tool === "agent" && s.status === "running"); + if (agentStep) { + agentStep.status = "done"; + agentStep.duration = Date.now() - agentStep.timestamp; + agentStep.description = "Agent finished"; + } + this._onDidChangeTreeData.fire(); + break; + } + + case "tool_execution_start": { + const toolName = String(evt.toolName ?? ""); + const toolInput = (evt.toolInput ?? {}) as Record; + const toolUseId = String(evt.toolUseId ?? ""); + const description = describeStep(toolName, toolInput); + + const id = this.nextId++; + this.steps.push({ + id, + tool: toolName, + description, + status: "running", + timestamp: Date.now(), + }); + + if (toolUseId) { + this.runningTools.set(toolUseId, id); + } + + // Cap at 200 steps + while (this.steps.length > 200) { + this.steps.shift(); + } + + this._onDidChangeTreeData.fire(); + break; + } + + case "tool_execution_end": { + const toolUseId = String(evt.toolUseId ?? ""); + const stepId = this.runningTools.get(toolUseId); + if (stepId !== undefined) { + this.runningTools.delete(toolUseId); + const step = this.steps.find((s) => s.id === stepId); + if (step) { + const isError = evt.error === true || evt.isError === true; + step.status = isError ? "error" : "done"; + step.duration = Date.now() - step.timestamp; + this._onDidChangeTreeData.fire(); + } + } + break; + } + } + } +} + +function stepIcon(status: string): vscode.ThemeIcon { + switch (status) { + case "running": + return new vscode.ThemeIcon("sync~spin", new vscode.ThemeColor("charts.yellow")); + case "done": + return new vscode.ThemeIcon("pass", new vscode.ThemeColor("testing.iconPassed")); + case "error": + return new vscode.ThemeIcon("error", new vscode.ThemeColor("testing.iconFailed")); + default: + return new vscode.ThemeIcon("circle-outline"); + } +} + +function describeStep(toolName: string, input: Record): string { + switch (toolName) { + case "Read": { + const p = String(input.file_path ?? input.path ?? ""); + return `Read ${p.split(/[\\/]/).pop() ?? p}`; + } + case "Write": { + const p = String(input.file_path ?? ""); + return `Write ${p.split(/[\\/]/).pop() ?? p}`; + } + case "Edit": { + const p = String(input.file_path ?? ""); + return `Edit ${p.split(/[\\/]/).pop() ?? p}`; + } + case "Bash": + return `$ ${String(input.command ?? "").slice(0, 50)}`; + case "Grep": + return `Grep: ${String(input.pattern ?? "").slice(0, 40)}`; + case "Glob": + return `Glob: ${String(input.pattern ?? "").slice(0, 40)}`; + default: + return toolName; + } +} diff --git a/vscode-extension/src/scm-provider.ts b/vscode-extension/src/scm-provider.ts new file mode 100644 index 000000000..2320ab6d5 --- /dev/null +++ b/vscode-extension/src/scm-provider.ts @@ -0,0 +1,124 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import type { GsdChangeTracker } from "./change-tracker.js"; + +const GSD_ORIGINAL_SCHEME = "gsd-original"; + +/** + * Source Control provider that shows files modified by the GSD agent + * in a dedicated "GSD Agent" section of the Source Control panel. + * Supports QuickDiff to show before/after diffs, and accept/discard per-file. + */ +export class GsdScmProvider implements vscode.Disposable { + private readonly scm: vscode.SourceControl; + private readonly changesGroup: vscode.SourceControlResourceGroup; + private readonly contentProvider: GsdOriginalContentProvider; + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly tracker: GsdChangeTracker, + private readonly workspaceRoot: string, + ) { + // Register content provider for original file contents + this.contentProvider = new GsdOriginalContentProvider(tracker); + this.disposables.push( + vscode.workspace.registerTextDocumentContentProvider( + GSD_ORIGINAL_SCHEME, + this.contentProvider, + ), + ); + + // Create source control instance + this.scm = vscode.scm.createSourceControl( + "gsd", + "GSD Agent", + vscode.Uri.file(workspaceRoot), + ); + this.scm.quickDiffProvider = { + provideOriginalResource: (uri: vscode.Uri): vscode.Uri | undefined => { + const filePath = uri.fsPath; + if (this.tracker.getOriginal(filePath) !== undefined) { + return uri.with({ scheme: GSD_ORIGINAL_SCHEME }); + } + return undefined; + }, + }; + this.scm.inputBox.placeholder = "Describe changes to accept..."; + this.scm.acceptInputCommand = { + command: "gsd.acceptAllChanges", + title: "Accept All", + }; + this.scm.count = 0; + this.disposables.push(this.scm); + + // Create resource group + this.changesGroup = this.scm.createResourceGroup("changes", "Agent Changes"); + this.changesGroup.hideWhenEmpty = true; + this.disposables.push(this.changesGroup); + + // Listen for change tracker updates + this.disposables.push( + tracker.onDidChange(() => this.refresh()), + ); + + this.refresh(); + } + + private refresh(): void { + const files = this.tracker.modifiedFiles; + this.changesGroup.resourceStates = files.map((filePath) => { + const uri = vscode.Uri.file(filePath); + const fileName = path.basename(filePath); + const relativePath = path.relative(this.workspaceRoot, filePath); + + const state: vscode.SourceControlResourceState = { + resourceUri: uri, + decorations: { + strikeThrough: false, + tooltip: `Modified by GSD Agent`, + light: { iconPath: new vscode.ThemeIcon("edit") }, + dark: { iconPath: new vscode.ThemeIcon("edit") }, + }, + command: { + command: "vscode.diff", + title: "Show Changes", + arguments: [ + uri.with({ scheme: GSD_ORIGINAL_SCHEME }), + uri, + `${fileName} (GSD Agent Changes)`, + ], + }, + }; + return state; + }); + this.scm.count = files.length; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + } +} + +/** + * TextDocumentContentProvider that serves the original (pre-agent) content + * of files via the `gsd-original:` URI scheme. + */ +class GsdOriginalContentProvider implements vscode.TextDocumentContentProvider { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + constructor(private readonly tracker: GsdChangeTracker) { + tracker.onDidChange((paths) => { + for (const p of paths) { + this._onDidChange.fire(vscode.Uri.file(p).with({ scheme: GSD_ORIGINAL_SCHEME })); + } + }); + } + + provideTextDocumentContent(uri: vscode.Uri): string { + const filePath = uri.with({ scheme: "file" }).fsPath; + return this.tracker.getOriginal(filePath) ?? ""; + } +} diff --git a/vscode-extension/src/session-tree.ts b/vscode-extension/src/session-tree.ts index e61898e0a..a38413be4 100644 --- a/vscode-extension/src/session-tree.ts +++ b/vscode-extension/src/session-tree.ts @@ -56,18 +56,35 @@ export class GsdSessionTreeProvider implements vscode.TreeDataProvider_.jsonl - const match = file.match(/^(\d+)_(.+)\.jsonl$/); - if (!match) { + const sessionFile = path.join(sessionDir, file); + + // Try two filename formats: + // 1. ISO timestamp: 2026-03-23T17-49-05-784Z_.jsonl + // 2. Unix timestamp: _.jsonl + const isoMatch = file.match(/^(\d{4}-\d{2}-\d{2}T[\d-]+Z)_(.+)\.jsonl$/); + const unixMatch = file.match(/^(\d{10,})_(.+)\.jsonl$/); + + let timestamp: Date; + let sessionId: string; + + if (isoMatch) { + // Convert ISO-like format (dashes instead of colons) back to parseable ISO + const isoStr = isoMatch[1].replace(/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d+)Z/, "$1:$2:$3.$4Z"); + timestamp = new Date(isoStr); + sessionId = isoMatch[2]; + } else if (unixMatch) { + timestamp = new Date(parseInt(unixMatch[1], 10)); + sessionId = unixMatch[2]; + } else { continue; } - const ts = parseInt(match[1], 10); - const sessionId = match[2]; - const sessionFile = path.join(sessionDir, file); + + if (isNaN(timestamp.getTime())) continue; + items.push({ - label: formatDate(new Date(ts)), + label: formatDate(timestamp), sessionFile, - timestamp: new Date(ts), + timestamp, sessionId, isCurrent: sessionFile === state.sessionFile, }); diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts index 12c718633..b8bb2aee0 100644 --- a/vscode-extension/src/sidebar.ts +++ b/vscode-extension/src/sidebar.ts @@ -2,8 +2,17 @@ import * as vscode from "vscode"; import type { GsdClient, SessionStats, ThinkingLevel } from "./gsd-client.js"; /** - * WebviewViewProvider that renders a sidebar panel showing connection status, - * model info, thinking level, token usage, cost, and quick action controls. + * Send a message through VS Code's Chat panel so the user sees the response. + * Opens the Chat panel and pre-fills the @gsd participant with the message. + */ +async function sendViaChat(message: string): Promise { + await vscode.commands.executeCommand("workbench.action.chat.open", { query: message }); +} + +/** + * WebviewViewProvider that renders a compact, card-based sidebar panel. + * Designed for information density without clutter — collapsible sections, + * hidden empty data, and consolidated action buttons. */ export class GsdSidebarProvider implements vscode.WebviewViewProvider { public static readonly viewId = "gsd-sidebar"; @@ -106,22 +115,18 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { await vscode.commands.executeCommand("gsd.copyLastResponse"); break; case "autoMode": - if (this.client.isConnected) { - await this.client.sendPrompt("/gsd auto").catch(() => {}); - } + await sendViaChat("@gsd /gsd auto"); break; case "nextUnit": - if (this.client.isConnected) { - await this.client.sendPrompt("/gsd next").catch(() => {}); - } + await sendViaChat("@gsd /gsd next"); break; case "quickTask": { const quickInput = await vscode.window.showInputBox({ prompt: "Describe the quick task", placeHolder: "e.g. fix the typo in README", }); - if (quickInput && this.client.isConnected) { - await this.client.sendPrompt(`/gsd quick ${quickInput}`).catch(() => {}); + if (quickInput) { + await sendViaChat(`@gsd /gsd quick ${quickInput}`); } break; } @@ -130,15 +135,13 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { prompt: "Capture a thought", placeHolder: "e.g. we should also handle the edge case for...", }); - if (thought && this.client.isConnected) { - await this.client.sendPrompt(`/gsd capture ${thought}`).catch(() => {}); + if (thought) { + await sendViaChat(`@gsd /gsd capture ${thought}`); } break; } case "status": - if (this.client.isConnected) { - await this.client.sendPrompt("/gsd status").catch(() => {}); - } + await sendViaChat("@gsd /gsd status"); break; case "forkSession": await vscode.commands.executeCommand("gsd.forkSession"); @@ -149,6 +152,9 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { case "toggleFollowUpMode": await vscode.commands.executeCommand("gsd.toggleFollowUpMode"); break; + case "showHistory": + await vscode.commands.executeCommand("gsd.showHistory"); + break; } }); @@ -168,6 +174,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { } let modelName = "N/A"; + let modelShort = ""; let sessionId = "N/A"; let sessionName = ""; let messageCount = 0; @@ -189,6 +196,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { modelName = state.model ? `${state.model.provider}/${state.model.id}` : "Not set"; + modelShort = state.model?.id ?? ""; sessionId = state.sessionId; sessionName = state.sessionName ?? ""; messageCount = state.messageCount; @@ -216,6 +224,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { this.view.webview.html = this.getHtml({ connected, modelName, + modelShort, sessionId, sessionName, messageCount, @@ -244,6 +253,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { private getHtml(info: { connected: boolean; modelName: string; + modelShort: string; sessionId: string; sessionName: string; messageCount: number; @@ -259,57 +269,49 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { followUpMode: "all" | "one-at-a-time"; }): string { const statusColor = info.connected ? "#4ec9b0" : "#f44747"; - const statusText = info.connected - ? info.isStreaming - ? "Processing..." - : info.isCompacting - ? "Compacting..." - : "Connected" - : "Disconnected"; + const statusLabel = info.isStreaming ? "Working" : info.isCompacting ? "Compacting" : info.connected ? "Connected" : "Disconnected"; - 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)}` : "-"; + // Model short name for header + const modelDisplay = info.modelShort || "N/A"; - const thinkingBadge = info.thinkingLevel !== "off" - ? `${info.thinkingLevel}` - : `off`; + // Session display — name or truncated ID + const sessionDisplay = info.sessionName || (info.sessionId !== "N/A" ? info.sessionId.slice(0, 8) : "N/A"); - const autoCompBadge = info.autoCompaction - ? `on` - : `off`; - - const autoRetryBadge = info.autoRetry - ? `on` - : `off`; - - const streamingIndicator = info.isStreaming - ? `
Agent is working...
` + // Cost for header + const costDisplay = info.stats?.totalCost !== undefined && info.stats.totalCost > 0 + ? `$${info.stats.totalCost.toFixed(4)}` : ""; - // Context window usage + // Context window const totalTokens = (info.stats?.inputTokens ?? 0) + (info.stats?.outputTokens ?? 0); const contextPct = info.contextWindow > 0 ? Math.min(100, Math.round((totalTokens / info.contextWindow) * 100)) : 0; const contextColor = contextPct > 80 ? "#f44747" : contextPct > 50 ? "#cca700" : "#4ec9b0"; - const contextLabel = info.contextWindow > 0 - ? `${contextPct}% (${Math.round(totalTokens / 1000)}k / ${Math.round(info.contextWindow / 1000)}k)` - : "N/A"; - const steeringBadge = info.steeringMode === "one-at-a-time" - ? `1-at-a-time` - : `all`; - const followUpBadge = info.followUpMode === "one-at-a-time" - ? `1-at-a-time` - : `all`; + // Only show stats that have real data + const hasStats = info.stats && ( + (info.stats.inputTokens !== undefined && info.stats.inputTokens > 0) || + (info.stats.outputTokens !== undefined && info.stats.outputTokens > 0) + ); const nonce = getNonce(); + // Build stat rows only for non-zero values + let statRows = ""; + if (hasStats && info.stats) { + const pairs: [string, string][] = []; + if (info.stats.inputTokens) pairs.push(["In", formatNum(info.stats.inputTokens)]); + if (info.stats.outputTokens) pairs.push(["Out", formatNum(info.stats.outputTokens)]); + if (info.stats.cacheReadTokens) pairs.push(["Cache R", formatNum(info.stats.cacheReadTokens)]); + if (info.stats.cacheWriteTokens) pairs.push(["Cache W", formatNum(info.stats.cacheWriteTokens)]); + if (info.stats.turnCount) pairs.push(["Turns", String(info.stats.turnCount)]); + if (info.stats.duration) pairs.push(["Time", `${Math.round(info.stats.duration / 1000)}s`]); + if (info.stats.totalCost !== undefined && info.stats.totalCost > 0) pairs.push(["Cost", `$${info.stats.totalCost.toFixed(4)}`]); + + statRows = pairs.map(([k, v]) => + `${k}${v}` + ).join(""); + } + return /* html */ ` @@ -317,291 +319,329 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider { -
-
- ${statusText} -
- - ${streamingIndicator} - -
-
Session
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Model${escapeHtml(info.modelName)}
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}
Steering${info.steeringMode === "one-at-a-time" ? "1-at-a-time" : "all"}
Follow-up${info.followUpMode === "one-at-a-time" ? "1-at-a-time" : "all"}
-
- - ${info.connected && info.stats ? ` -
-
Token Usage
-
- Input - ${inputTokens} - Output - ${outputTokens} - Cache read - ${cacheRead} - Cache write - ${cacheWrite} - Turns - ${turnCount} - Duration - ${duration} - Cost - ${cost} + ${info.connected ? this.getConnectedHtml(info, { + statusLabel, + modelDisplay, + sessionDisplay, + costDisplay, + contextPct, + contextColor, + hasStats: !!hasStats, + statRows, + nonce, + }) : ` +
+
+
+ Disconnected
- - ${info.contextWindow > 0 ? ` -
-
Context Window
-
-
-
-
${contextLabel}
+
+

Agent is not running

+
- ` : ""} - ` : ""} - - ${info.connected ? ` -
-
Workflow
-
-
- - -
-
- - -
-
- - -
-
-
- ` : ""} - -
-
Controls
-
- ${info.connected - ? ` -
- - -
-
- - -
-
- - -
` - : `` - } -
-
- - ${info.connected ? ` -
-
Actions
-
-
- - -
-
- - -
-
-
- ` : ""} + `}