feat(vscode): sidebar redesign, SCM provider, checkpoints, diagnostics [3/3]

Comprehensive vscode extension redesign with sidebar reorganization,
new features, and enhanced agent integration:

- Redesign sidebar UI: reduce 6 panels to 3, declutter layout
- SCM provider for tracking agent-modified files
- Checkpoint system for saving/restoring agent state
- Diagnostic integration for surfacing errors in editor
- Line-level editor decorations for agent-modified lines
- Git integration for visualizing agent changes
- Execution plan viewer for live agent step visualization
- Approval/permissions mode system
- Auto-inject editor selection and diagnostics in chat
- Route workflow buttons through Chat panel
- Handle extension UI requests from agent (select, confirm, input)
- Session persistence, ISO timestamp support, descriptive checkpoints
- Bump to v0.3.0
This commit is contained in:
Jeremy 2026-03-29 09:25:07 -05:00
parent 0a2c9b64c6
commit 59d80e200a
18 changed files with 2343 additions and 389 deletions

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

View file

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

View file

@ -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<string, string>;
}
/**
* 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<string, string>();
/** Set of file paths modified in the current agent turn */
private currentTurnFiles = new Set<string>();
/** Ordered list of checkpoints */
private _checkpoints: Checkpoint[] = [];
private nextCheckpointId = 1;
/** toolUseId → file path for in-flight tool executions */
private pendingTools = new Map<string, string>();
/** Whether the current turn has been described in the checkpoint label */
private turnDescribed = false;
private readonly _onDidChange = new vscode.EventEmitter<string[]>();
/** 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<void>();
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<boolean> {
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<number> {
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<number> {
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<string, unknown>;
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, unknown>): 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;
}
}

View file

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

View file

@ -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<Checkpoint>, vscode.Disposable {
public static readonly viewId = "gsd-checkpoints";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
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();
}
}
}

View file

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

View file

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

View file

@ -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<void> {
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<void> {
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<void> {
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<string> {
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);
}
});
});
}
}

View file

@ -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<string, unknown>): Promise<void> {
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<string, unknown>): void {
if (this.process?.stdin) {
this.process.stdin.write(JSON.stringify(data) + "\n");
}
}
private send(command: Record<string, unknown>): Promise<RpcResponse> {
if (!this.process?.stdin) {
return Promise.reject(new Error("GSD client not started"));

View file

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

View file

@ -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<ApprovalMode>();
readonly onModeChange = this._onModeChange.event;
constructor(private readonly client: GsdClient) {
// Load saved mode from configuration
this._mode = vscode.workspace.getConfiguration("gsd").get<ApprovalMode>("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>("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<void> {
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<ApprovalMode, string> = {
"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<void> {
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<void> {
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<string, unknown>;
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(() => {});
}
});
}
}

View file

@ -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<PlanStep>, vscode.Disposable {
public static readonly viewId = "gsd-plan";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private steps: PlanStep[] = [];
private nextId = 0;
private runningTools = new Map<string, number>(); // 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<string, unknown>;
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, unknown>): 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;
}
}

View file

@ -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<vscode.Uri>();
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) ?? "";
}
}

View file

@ -56,18 +56,35 @@ export class GsdSessionTreeProvider implements vscode.TreeDataProvider<SessionIt
const items: SessionItem[] = [];
for (const file of files) {
// Filename format: <unixTimestampMs>_<sessionId>.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_<sessionId>.jsonl
// 2. Unix timestamp: <unixTimestampMs>_<sessionId>.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,
});

View file

@ -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<void> {
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"
? `<span class="badge">${info.thinkingLevel}</span>`
: `<span class="badge muted">off</span>`;
// 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
? `<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>`
// 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"
? `<span class="badge">1-at-a-time</span>`
: `<span class="badge muted">all</span>`;
const followUpBadge = info.followUpMode === "one-at-a-time"
? `<span class="badge">1-at-a-time</span>`
: `<span class="badge muted">all</span>`;
// 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]) =>
`<span class="stat-label">${k}</span><span class="stat-value">${v}</span>`
).join("");
}
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
@ -317,291 +319,329 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
padding: 12px;
margin: 0;
padding: 8px;
}
.status-row {
/* ---- Header card ---- */
.header {
padding: 10px 12px;
border-radius: 6px;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
margin-bottom: 8px;
}
.header-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.status-dot {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: ${statusColor};
flex-shrink: 0;
}
.streaming-indicator {
.status-label {
font-size: 11px;
opacity: 0.7;
flex-shrink: 0;
}
.header-model {
margin-left: auto;
font-size: 11px;
font-weight: 600;
opacity: 0.85;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-model:hover { opacity: 1; }
.header-cost {
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.6;
flex-shrink: 0;
}
.header-sub {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 11px;
opacity: 0.6;
}
.header-sub .sep { opacity: 0.3; }
.session-name {
cursor: pointer;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-name:hover { opacity: 1; text-decoration: underline; }
/* ---- Streaming banner ---- */
.streaming {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
margin-bottom: 12px;
background: var(--vscode-editor-background);
border-radius: 4px;
margin-bottom: 8px;
background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent);
border: 1px solid var(--vscode-focusBorder);
border-radius: 6px;
font-size: 12px;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid var(--vscode-foreground);
width: 10px; height: 10px;
border: 2px solid var(--vscode-focusBorder);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.section {
margin-bottom: 14px;
}
.section-title {
font-size: 11px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.info-table {
width: 100%;
}
.info-table td {
padding: 3px 0;
vertical-align: middle;
}
.info-table td:first-child {
opacity: 0.7;
padding-right: 12px;
white-space: nowrap;
}
.info-table td:last-child {
word-break: break-all;
}
.badge {
display: inline-block;
padding: 1px 6px;
@keyframes spin { to { transform: rotate(360deg); } }
.streaming-abort {
margin-left: auto;
font-size: 10px;
padding: 2px 8px;
border: 1px solid var(--vscode-foreground);
background: transparent;
color: var(--vscode-foreground);
border-radius: 3px;
font-size: 11px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.badge.muted {
opacity: 0.5;
}
.badge.clickable {
cursor: pointer;
opacity: 0.6;
}
.badge.clickable:hover {
opacity: 0.8;
.streaming-abort:hover { opacity: 1; }
/* ---- Context bar (inline in header) ---- */
.context-bar {
margin-top: 8px;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-row {
display: flex;
gap: 6px;
}
.btn-row button {
flex: 1;
}
button {
display: block;
.context-track {
width: 100%;
padding: 6px 14px;
border: none;
height: 3px;
background: var(--vscode-panel-border);
border-radius: 2px;
overflow: hidden;
}
.context-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.context-text {
font-size: 10px;
opacity: 0.5;
margin-top: 2px;
}
/* ---- Collapsible section ---- */
.section {
margin-bottom: 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
button.secondary {
color: var(--vscode-button-secondaryForeground);
background: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.token-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
font-size: 12px;
}
.token-stats .label {
user-select: none;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
background: var(--vscode-editor-background);
}
.token-stats .value {
.section-header:hover { opacity: 1; }
.chevron {
font-size: 10px;
transition: transform 0.15s;
}
.section.collapsed .section-body { display: none; }
.section.collapsed .chevron { transform: rotate(-90deg); }
.section-body {
padding: 6px 10px 8px;
}
/* ---- Stats grid ---- */
.stats-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 10px;
font-size: 11px;
}
.stat-label { opacity: 0.6; }
.stat-value {
text-align: right;
font-variant-numeric: tabular-nums;
}
.context-bar-outer {
width: 100%;
height: 6px;
background: var(--vscode-editor-background);
border-radius: 3px;
overflow: hidden;
margin: 4px 0 2px;
}
.context-bar-inner {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.context-label {
/* ---- Toggle row ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
font-size: 11px;
opacity: 0.7;
}
.toggle-label { opacity: 0.7; }
.toggle-pill {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.toggle-pill.on {
background: color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
border-color: var(--vscode-focusBorder);
color: var(--vscode-foreground);
}
.toggle-pill.off {
background: transparent;
border-color: var(--vscode-panel-border);
opacity: 0.5;
}
.toggle-pill:hover { opacity: 1; }
/* ---- Buttons ---- */
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.actions.three-col {
grid-template-columns: 1fr 1fr 1fr;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 5px 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
width: auto;
}
.action-btn:hover {
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-focusBorder);
}
.action-btn.primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border-color: var(--vscode-button-background);
font-weight: 600;
}
.action-btn.primary:hover {
background: var(--vscode-button-hoverBackground);
}
.action-btn.danger {
border-color: #f44747;
color: #f44747;
}
.action-btn.danger:hover {
background: color-mix(in srgb, #f44747 15%, transparent);
}
.action-btn.full {
grid-column: 1 / -1;
}
/* ---- Disconnected state ---- */
.disconnected {
text-align: center;
padding: 20px 12px;
}
.disconnected p {
opacity: 0.5;
font-size: 12px;
margin-bottom: 12px;
}
.start-btn {
padding: 8px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: var(--vscode-font-size);
font-weight: 600;
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
width: auto;
display: inline-block;
}
.start-btn:hover {
background: var(--vscode-button-hoverBackground);
}
</style>
</head>
<body>
<div class="status-row">
<div class="status-dot"></div>
<strong>${statusText}</strong>
</div>
${streamingIndicator}
<div class="section">
<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)}
${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>
</tr>
<tr>
<td>Auto-compact</td>
<td>${autoCompBadge}</td>
</tr>
<tr>
<td>Auto-retry</td>
<td>${autoRetryBadge}</td>
</tr>
<tr>
<td>Steering</td>
<td><span class="badge clickable" data-command="toggleSteeringMode">${info.steeringMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span></td>
</tr>
<tr>
<td>Follow-up</td>
<td><span class="badge clickable" data-command="toggleFollowUpMode">${info.followUpMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span></td>
</tr>
</table>
</div>
${info.connected && info.stats ? `
<div class="section">
<div class="section-title">Token Usage</div>
<div class="token-stats">
<span class="label">Input</span>
<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>
${info.connected ? this.getConnectedHtml(info, {
statusLabel,
modelDisplay,
sessionDisplay,
costDisplay,
contextPct,
contextColor,
hasStats: !!hasStats,
statRows,
nonce,
}) : `
<div class="header">
<div class="header-top">
<div class="status-dot"></div>
<span class="status-label">Disconnected</span>
</div>
</div>
${info.contextWindow > 0 ? `
<div class="section">
<div class="section-title">Context Window</div>
<div class="context-bar-outer">
<div class="context-bar-inner" style="width: ${contextPct}%; background: ${contextColor};"></div>
</div>
<div class="context-label">${contextLabel}</div>
<div class="disconnected">
<p>Agent is not running</p>
<button class="start-btn" data-command="start">Start Agent</button>
</div>
` : ""}
` : ""}
${info.connected ? `
<div class="section">
<div class="section-title">Workflow</div>
<div class="btn-group">
<div class="btn-row">
<button data-command="autoMode">Auto</button>
<button class="secondary" data-command="nextUnit">Next</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="quickTask">Quick</button>
<button class="secondary" data-command="capture">Capture</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="status">Status</button>
<button class="secondary" data-command="forkSession">Fork</button>
</div>
</div>
</div>
` : ""}
<div class="section">
<div class="section-title">Controls</div>
<div class="btn-group">
${info.connected
? `<button data-command="stop">Stop Agent</button>
<div class="btn-row">
<button class="secondary" data-command="newSession">New Session</button>
<button class="secondary" data-command="switchModel">Model</button>
</div>
<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>`
}
</div>
</div>
${info.connected ? `
<div class="section">
<div class="section-title">Actions</div>
<div class="btn-group">
<div class="btn-row">
<button class="secondary" data-command="compact">Compact</button>
<button class="secondary" data-command="exportHtml">Export</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="abort">Abort</button>
<button class="secondary" data-command="listCommands">Commands</button>
</div>
</div>
</div>
` : ""}
`}
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const stored = vscode.getState() || {};
// Restore collapsed state
document.querySelectorAll('.section').forEach(s => {
const id = s.dataset.section;
if (id && stored[id] === 'collapsed') s.classList.add('collapsed');
});
document.addEventListener('click', (e) => {
// Section toggle
const header = e.target.closest('.section-header');
if (header) {
const section = header.parentElement;
section.classList.toggle('collapsed');
const id = section.dataset.section;
if (id) {
const state = vscode.getState() || {};
state[id] = section.classList.contains('collapsed') ? 'collapsed' : 'open';
vscode.setState(state);
}
return;
}
// Button/command click
const btn = e.target.closest('[data-command]');
if (btn) {
vscode.postMessage({ command: btn.dataset.command });
@ -611,6 +651,144 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
</body>
</html>`;
}
private getConnectedHtml(
info: {
connected: boolean;
modelName: string;
modelShort: string;
sessionId: string;
sessionName: string;
messageCount: number;
pendingMessageCount: number;
thinkingLevel: ThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
autoCompaction: boolean;
autoRetry: boolean;
stats: SessionStats | null;
contextWindow: number;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
},
ui: {
statusLabel: string;
modelDisplay: string;
sessionDisplay: string;
costDisplay: string;
contextPct: number;
contextColor: string;
hasStats: boolean;
statRows: string;
nonce: string;
},
): string {
const pendingBadge = info.pendingMessageCount > 0
? ` <span style="opacity:0.5">+${info.pendingMessageCount}</span>`
: "";
return `
<!-- Header card -->
<div class="header">
<div class="header-top">
<div class="status-dot"></div>
<span class="status-label">${ui.statusLabel}</span>
<span class="header-model" data-command="switchModel" title="${escapeHtml(info.modelName)}">${escapeHtml(ui.modelDisplay)}</span>
${ui.costDisplay ? `<span class="header-cost">${ui.costDisplay}</span>` : ""}
</div>
<div class="header-sub">
<span class="session-name" data-command="setSessionName" title="${escapeHtml(info.sessionId)}">${escapeHtml(ui.sessionDisplay)}</span>
<span class="sep">/</span>
<span>${info.messageCount} msg${pendingBadge}</span>
<span class="sep">/</span>
<span data-command="cycleThinking" style="cursor:pointer" title="Click to cycle thinking level">${info.thinkingLevel === "off" ? "no think" : info.thinkingLevel}</span>
</div>
${info.contextWindow > 0 ? `
<div class="context-bar">
<div class="context-track">
<div class="context-fill" style="width:${ui.contextPct}%;background:${ui.contextColor}"></div>
</div>
<div class="context-text">${ui.contextPct}% context (${formatNum((info.stats?.inputTokens ?? 0) + (info.stats?.outputTokens ?? 0))} / ${formatNum(info.contextWindow)})</div>
</div>
` : ""}
</div>
${info.isStreaming ? `
<div class="streaming">
<span class="spinner"></span>
<span>Agent is working...</span>
<button class="streaming-abort" data-command="abort">Stop</button>
</div>
` : ""}
<!-- Workflow -->
<div class="section" data-section="workflow">
<div class="section-header"><span class="chevron">&#9660;</span> Workflow</div>
<div class="section-body">
<div class="actions">
<button class="action-btn primary" data-command="autoMode">Auto</button>
<button class="action-btn" data-command="nextUnit">Next</button>
<button class="action-btn" data-command="quickTask">Quick</button>
<button class="action-btn" data-command="capture">Capture</button>
</div>
</div>
</div>
${ui.hasStats ? `
<!-- Stats -->
<div class="section" data-section="stats">
<div class="section-header"><span class="chevron">&#9660;</span> Stats</div>
<div class="section-body">
<div class="stats-grid">${ui.statRows}</div>
</div>
</div>
` : ""}
<!-- Actions -->
<div class="section" data-section="actions">
<div class="section-header"><span class="chevron">&#9660;</span> Actions</div>
<div class="section-body">
<div class="actions three-col">
<button class="action-btn" data-command="newSession">New</button>
<button class="action-btn" data-command="compact">Compact</button>
<button class="action-btn" data-command="copyLastResponse">Copy</button>
<button class="action-btn" data-command="status">Status</button>
<button class="action-btn" data-command="fixProblemsInFile">Fix Errs</button>
<button class="action-btn" data-command="showHistory">History</button>
</div>
<div style="margin-top:6px">
<button class="action-btn danger full" data-command="stop">Stop Agent</button>
</div>
</div>
</div>
<!-- Settings (collapsed by default) -->
<div class="section collapsed" data-section="settings">
<div class="section-header"><span class="chevron">&#9660;</span> Settings</div>
<div class="section-body">
<div class="toggle-row">
<span class="toggle-label">Auto-compact</span>
<span class="toggle-pill ${info.autoCompaction ? "on" : "off"}" data-command="toggleAutoCompaction">${info.autoCompaction ? "on" : "off"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Auto-retry</span>
<span class="toggle-pill ${info.autoRetry ? "on" : "off"}" data-command="toggleAutoRetry">${info.autoRetry ? "on" : "off"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Steering</span>
<span class="toggle-pill ${info.steeringMode === "one-at-a-time" ? "on" : "off"}" data-command="toggleSteeringMode">${info.steeringMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Follow-up</span>
<span class="toggle-pill ${info.followUpMode === "one-at-a-time" ? "on" : "off"}" data-command="toggleFollowUpMode">${info.followUpMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Approval</span>
<span class="toggle-pill on" data-command="selectApprovalMode">change</span>
</div>
</div>
</div>`;
}
}
function escapeHtml(text: string): string {
@ -621,6 +799,12 @@ function escapeHtml(text: string): string {
.replace(/"/g, "&quot;");
}
function formatNum(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
function getNonce(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let nonce = "";

View file

@ -77,7 +77,9 @@ export class GsdSlashCompletionProvider
private async refreshCache(): Promise<void> {
try {
this.cachedCommands = await this.client.getCommands();
const all = await this.client.getCommands();
// Only show /gsd commands — filter out unrelated extension/skill commands
this.cachedCommands = all.filter((cmd) => cmd.name.startsWith("gsd"));
} catch {
// Silently ignore — agent may not be ready yet.
}