chore: remove vscode extension and tune knip
Some checks are pending
sf self-deploy / build, test, and publish server image (push) Waiting to run
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions

This commit is contained in:
Mikael Hugo 2026-05-18 04:49:25 +02:00
parent ab6da23789
commit 062e8e3c9f
45 changed files with 143 additions and 11275 deletions

View file

@ -240,9 +240,8 @@ not a synonym for JSON. See `docs/specs/sf-operating-model.md`.
Source placement follows the same model. `src/resources/extensions/sf/` owns the
SF flow extension, `src/headless*.ts` owns the `sf headless` machine-surface
command path, `web/` owns the browser surface, `vscode-extension/` owns the
editor surface, `packages/rpc-client/` owns reusable RPC adapter code, and
`packages/*` own reusable workspace packages. See
command path, `web/` owns the browser surface, `packages/rpc-client/` owns
reusable RPC adapter code, and `packages/*` own reusable workspace packages. See
`docs/specs/sf-operating-model.md`.
Promoted artifacts — milestone summaries, architecture decision records (ADRs), and durable specifications — belong in tracked documentation directories:

View file

@ -29,7 +29,6 @@ Guides for installing, configuring, and using SF day-to-day. Located in [`user-d
| [Migration from v1](./user-docs/migration.md) | Migrating `.planning` directories from the original SF |
| [Troubleshooting](./user-docs/troubleshooting.md) | Common issues, `/sf doctor` (real-time visibility v2.40), `/sf forensics` (full debugger v2.40), and recovery procedures |
| [Server Interface](./user-docs/web-interface.md) | Browser-based project management with `sf server` (v2.41) |
| [VS Code Extension](../vscode-extension/README.md) | Chat participant, sidebar dashboard, and RPC integration for VS Code |
## Architecture & Internals

View file

@ -6,7 +6,7 @@
## Source Placement Axes (current)
- **Flow engine**: core workflow/control logic in `sf/*`, `auto*`, `core/*`, and `agent-loop.ts`-style paths.
- **Surfaces**: CLI/session I/O (`src/cli.ts`, `src/help-text.ts`), machine surface (`src/headless*.ts` via `sf headless`), web (`web/`), editor (`vscode-extension/`), and interactive TUI (`packages/pi-tui/*`).
- **Surfaces**: CLI/session I/O (`src/cli.ts`, `src/help-text.ts`), machine surface (`src/headless*.ts` via `sf headless`), web (`web/`), and interactive TUI (`packages/pi-tui/*`).
- **Protocols / adapters**: `packages/rpc-client/*`, `packages/pi-coding-agent/src/modes/rpc/*`, `src/web/*`, and HTTP route surfaces.
- **Output formats**: `text`, `json`, `stream-json` with JSONL event framing in headless/RPC paths.
- **Run control + permissions**: manual/assisted/autonomous and restricted/normal/trusted/unrestricted are independent axes selected and enforced by command/session policy.
@ -72,7 +72,6 @@
| **TUI Components** | Terminal UI component library (pi-tui) |
| **Universal Config** | Multi-tool configuration file discovery |
| **Voice** | Voice input extension (Swift/Python) |
| **VS Code Extension** | VS Code sidebar, chat participant, RPC client |
| **Web Mode** | Web server service layer and RPC bridge |
| **Web UI** | Next.js frontend components, pages, hooks |
| **Worktree** | Git worktree lifecycle, sync, name generation |
@ -848,17 +847,6 @@ agents are loaded as part of SF control flows and may declare named
---
## vscode-extension/ — VS Code Extension
| File | System Label(s) | Description |
|------|-----------------|-------------|
| vscode-extension/src/extension.ts | VS Code Extension | Extension activation, client management, command registration |
| vscode-extension/src/sf-client.ts | VS Code Extension, RPC | RPC client for SF agent communication |
| vscode-extension/src/chat-participant.ts | VS Code Extension | Chat participant for @sf command |
| vscode-extension/src/sidebar.ts | VS Code Extension | Sidebar webview provider with status display |
---
## packages/rpc-client/src/ — Protocol / Adapter
| File | System Label(s) | Description |
@ -1036,7 +1024,6 @@ Quick lookup: which files are part of each system?
| **TUI Components** | packages/pi-tui/src/*, pi-coding-agent/src/modes/interactive/components/*, pi-coding-agent/src/modes/interactive/controllers/* |
| **Universal Config** | src/resources/extensions/universal-config/* |
| **Voice** | src/resources/extensions/voice/* |
| **VS Code Extension** | vscode-extension/src/* |
| **Web Mode** | src/web/*.ts, src/web-mode.ts |
| **Web UI** | web/app/*.tsx, web/components/*, web/hooks/*, web/lib/* |
| **Worktree** | src/worktree-cli.ts, src/worktree-name-gen.ts, sf/worktree*.ts, tests/repro-worktree-bug/* |

View file

@ -21,7 +21,6 @@ sf (CLI binary)
sf headless Machine surface command (`src/headless*.ts`)
web/ Web surface (web mode + web UI runtime bridge)
vscode-extension/ Editor surface integration (VS Code chat + sidebar + RPC)
```
@ -43,7 +42,7 @@ The five-axis placement is:
- **Flow engine:** `sf` core and auto/workflow modules (`auto*`, `sf/*`, `core/*`).
- **Surfaces:** CLI via `src/cli.ts`, machine via `src/headless*.ts`, web via `web/*`,
editor/editor-adjacent via `vscode-extension/*`, and TUI via `packages/pi-tui/*`.
and TUI via `packages/pi-tui/*`.
- **Workspace packages:** `packages/*` contains shared modules (AI, agent core, tui, native, rpc-client), with protocol adapters in `packages/rpc-client/*`.
- **Protocols / adapters:** ACP, RPC, stdio JSON-RPC, HTTP, and adapter modules in
`packages/rpc-client/*` plus `packages/pi-coding-agent/src/modes/rpc/*`.

View file

@ -12,7 +12,7 @@
- Source schema version: 45
- DB planning rows: milestones=1, slices=0, tasks=0
- DB spec rows: milestone_specs=1, slice_specs=0, task_specs=0
- Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `vscode-extension/`, `packages/`
- Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `packages/`
This file is a human export for review, navigation, and git history. Generated docs are allowed to change because Git keeps the human-facing history. If SF needs operational history or future-use knowledge, store it in `.sf`/DB-backed state instead of relying on this export.
@ -186,7 +186,6 @@ runtime.
- `src/cli.ts` and `src/help-text.ts` own CLI/session entrypoint behavior and command help.
- `src/headless*.ts` owns the existing `sf headless` machine-surface command path. Keep the command name; describe it as the machine surface in product language.
- `web/` owns the browser surface.
- `vscode-extension/` owns the editor surface.
- `packages/pi-tui/` owns reusable TUI primitives and terminal UI components.
### Protocols And Adapters

View file

@ -245,18 +245,6 @@ All state lives on disk in `.sf/`:
---
## VS Code Extension
SF is also available as a VS Code extension. Install from the marketplace (publisher: FluxLabs) or search for "SF" in VS Code extensions:
- **`@sf` chat participant** — talk to the agent in VS Code Chat
- **Sidebar dashboard** — connection status, model info, token usage
- **Full command palette** — start/stop agent, switch models, export sessions
The CLI (`singularity-forge`) must be installed first — the extension connects to it via RPC.
---
## Server Interface
SF has a browser-based interface for visual project management:

124
knip.json Normal file
View file

@ -0,0 +1,124 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"ignore": [
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/coverage/**",
"**/.turbo/**",
"**/.cache/**",
"**/*.d.ts",
"packages/coding-agent/src/core/export-html/**",
"packages/coding-agent/src/resources/extensions/**",
"scripts/tmp-check-test-imports/**",
"src/resources/extensions/**/dist/**",
"src/resources/extensions/**",
"synthlang-runner/**",
"web/.next/**",
"web/out/**"
],
"ignoreBinaries": ["knip"],
"ignoreDependencies": [
"@anthropic-ai/sdk",
"@anthropic-ai/vertex-sdk",
"@aws-sdk/client-bedrock-runtime",
"@eslint/eslintrc",
"@google/genai",
"@hookform/resolvers",
"@mariozechner/jiti",
"@mistralai/mistralai",
"@octokit/rest",
"@silvia-odwyer/photon-node",
"@smithy/node-http-handler",
"@types/diff",
"@types/express",
"@types/mime-types",
"@types/picomatch",
"@types/react",
"ajv-formats",
"autoprefixer",
"chalk",
"chokidar",
"date-fns",
"diff",
"discord.js",
"esbuild",
"express",
"file-type",
"get-east-asian-width",
"hosted-git-info",
"ignore",
"jsonrepair",
"marked",
"mime-types",
"openai",
"picomatch",
"proper-lockfile",
"proxy-agent",
"react",
"remark-parse",
"tailwindcss",
"tw-animate-css",
"typescript-language-server",
"undici",
"unified",
"unist-util-visit",
"zod",
"zod-to-json-schema"
],
"ignoreUnresolved": [
"/^\\.\\.\\/resources\\/extensions\\//",
"/^\\.\\.\\/\\.\\.\\/resources\\/extensions\\//",
"/^\\.\\.\\/\\.\\.\\/src\\/resources\\/extensions\\//"
],
"workspaces": {
".": {
"entry": [
"src/loader.ts",
"src/cli.ts",
"src/headless.ts",
"src/headless*.ts",
"src/web-mode.ts",
"scripts/**/*.{js,cjs,mjs,ts}",
"tests/**/*.{js,cjs,mjs,ts}",
"src/tests/**/*.{js,cjs,mjs,ts}",
"src/resources/extensions/*/index.{js,cjs,mjs,ts}",
"src/resources/extensions/**/*.test.{js,cjs,mjs,ts}"
],
"project": [
"src/**/*.{js,cjs,mjs,ts,tsx}",
"scripts/**/*.{js,cjs,mjs,ts}",
"tests/**/*.{js,cjs,mjs,ts}",
"docker/**/*.{js,cjs,mjs,ts}",
"rust-engine/scripts/**/*.{js,cjs,mjs,ts}",
"*.config.{js,cjs,mjs,ts}",
"*.{js,cjs,mjs,ts}"
]
},
"web": {
"entry": [
"app/**/*.{ts,tsx}",
"components/**/*.{ts,tsx}",
"hooks/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
"next.config.{js,mjs,ts}",
"middleware.{js,ts}"
],
"project": [
"app/**/*.{ts,tsx}",
"components/**/*.{ts,tsx}",
"hooks/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
"*.{js,cjs,mjs,ts}"
]
},
"packages/*": {
"entry": [
"src/index.{js,ts}",
"src/cli.{js,ts}",
"src/**/*.test.{js,mjs,ts,tsx}"
],
"project": ["src/**/*.{js,cjs,mjs,ts,tsx}"]
}
}
}

792
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -104,6 +104,9 @@
"format:check": "biome format .",
"lint": "npm run check:versioned-json && biome check .",
"lint:fix": "npm run check:versioned-json && biome check --write .",
"knip": "npx knip --config knip.json",
"knip:deps": "npx knip --config knip.json --include dependencies,unlisted,binaries,catalog",
"knip:exports": "npx knip --config knip.json --exports",
"pipeline:version-stamp": "node scripts/version-stamp.mjs",
"release:changelog": "node scripts/generate-changelog.mjs",
"release:bump": "node scripts/bump-version.mjs",
@ -145,9 +148,9 @@
"fast-check": "^4.8.0",
"file-type": "^21.1.1",
"get-east-asian-width": "^1.6.0",
"google-auth-library": "^10.6.2",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"ink": "^7.0.3",
"jsonrepair": "^3.14.0",
"markdownlint": "^0.40.0",
"marked": "^18.0.3",

View file

@ -30,8 +30,6 @@ const coreDir = join(__dirname, "..");
* it does not need the guard and should NOT appear here.
*/
const SPAWN_FILES_NEEDING_SHELL_GUARD = [
// Extension's SF client — spawns the `sf` binary which is a .cmd on Windows
join(coreDir, "..", "..", "..", "vscode-extension", "src", "sf-client.ts"),
// exec.ts — used by extensions to run arbitrary commands
join(coreDir, "exec.ts"),
// LSP index — spawns project-type commands (tsc, cargo, etc.)

View file

@ -72,7 +72,6 @@ const RISK_TIERS = {
"Migration",
"Onboarding",
"Memory Extension",
"VS Code Extension",
"Voice",
"CMux",
"Mac Tools",

View file

@ -37,4 +37,3 @@ npm run copy-resources # recompile src/resources/extensions after editing .ts fi
| `packages/` | Seven npm workspace packages |
| `web/` | Next.js browser surface |
| `src/headless*.ts` | `sf headless` machine-surface command |
| `vscode-extension/` | Editor surface |

View file

@ -81,7 +81,7 @@ export function buildWorkingModelInputs(basePath) {
`- Optional knowledge: \`.sf/KNOWLEDGE.md\` (${present(join(sfDir, "KNOWLEDGE.md"))})`,
`- Optional preferences: \`.sf/preferences.yaml\` (${present(join(sfDir, "preferences.yaml"))})`,
readDbSummary(basePath),
"- Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `vscode-extension/`, `packages/`",
"- Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `packages/`",
"",
"This file is a human export for review, navigation, and git history. Generated docs are allowed to change because Git keeps the human-facing history. If SF needs operational history or future-use knowledge, store it in `.sf`/DB-backed state instead of relying on this export.",
"",
@ -214,7 +214,6 @@ export function generateOperatingModelSpec(basePath) {
"- `src/cli.ts` and `src/help-text.ts` own CLI/session entrypoint behavior and command help.",
"- `src/headless*.ts` owns the existing `sf headless` machine-surface command path. Keep the command name; describe it as the machine surface in product language.",
"- `web/` owns the browser surface.",
"- `vscode-extension/` owns the editor surface.",
"- `packages/tui/` owns reusable TUI primitives and terminal UI components.",
"",
"### Protocols And Adapters",

View file

@ -1,24 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "vitest";
const extensionSource = readFileSync(
join(process.cwd(), "vscode-extension", "src", "extension.ts"),
"utf-8",
);
test("VS Code startup uses inspected global/default config for binary path and auto-start", () => {
assert.match(extensionSource, /inspect<T>\(key\)/);
assert.match(extensionSource, /globalValue \?\? inspected\?\.defaultValue/);
assert.match(
extensionSource,
/binaryPath:\s*getTrustedConfigurationValue\("sf",\s*"binaryPath",\s*"sf"\)/,
);
assert.match(
extensionSource,
/autoStart:\s*getTrustedConfigurationValue\("sf",\s*"autoStart",\s*false\)/,
);
assert.doesNotMatch(extensionSource, /config\.get<string>\("binaryPath"/);
assert.doesNotMatch(extensionSource, /config\.get<boolean>\("autoStart"/);
});

View file

@ -54,10 +54,6 @@ test("encodeCwd produces a filesystem-safe token for Windows paths", () => {
});
test("Windows launch points use shell-safe shims", () => {
const sfClient = readFileSync(
path.join(process.cwd(), "vscode-extension", "src", "sf-client.ts"),
"utf8",
);
const updateService = readFileSync(
path.join(process.cwd(), "src", "web", "update-service.ts"),
"utf8",
@ -78,7 +74,6 @@ test("Windows launch points use shell-safe shims", () => {
"utf8",
);
assert.match(sfClient, /shell:\s*process\.platform === "win32"/);
assert.match(updateService, /npm\.cmd/);
assert.match(preExecution, /npm\.cmd/);
assert.match(validatePack, /shell:\s*process\.platform === ["']win32["']/);

View file

@ -1,3 +0,0 @@
dist/
node_modules/
*.vsix

View file

@ -1,9 +0,0 @@
.vscode/**
.vscode-test/**
src/**
.gitignore
tsconfig.json
**/*.ts
!dist/**
node_modules/**
**/*.map

View file

@ -1,58 +0,0 @@
# Changelog
## [0.3.0]
### Added
- **SCM provider** — "SF 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 `/sf` commands only
- **Checkpoint labels** show timestamp + first action (e.g., "10:32 — Edit sidebar.ts")
- **Session tree** supports ISO timestamp filenames (SF'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 (SF Agent, Sessions, Activity)
- **Settings section** starts collapsed by default
## [0.2.0]
### Added
- **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 SF
- **8 new commands** (33 total)
## [0.1.0]
Initial release.
- Full RPC client — spawns `sf --mode rpc`, JSON line framing, all RPC commands
- Sidebar dashboard — connection status, model info, thinking level, token usage, cost, quick actions
- Chat participant — `@sf` in VS Code Chat with streaming responses
- File decorations — "G" badge on files modified by the agent
- 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 `/sf` commands
- Code lens — "Ask SF" 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,21 +0,0 @@
MIT License
Copyright (c) 2026 Lex Christopherson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,196 +0,0 @@
# SF-2 — VS Code Extension
Control the [SF-2 coding agent](https://github.com/singularity-forge/sf-run) directly from VS Code. Run autonomous coding sessions, chat with `@sf`, monitor agent activity in real-time, review and accept/reject changes, and manage your workflow — all without leaving the editor.
![SF Extension Overview](docs/images/overview.png)
## Requirements
- **SF-2** installed globally: `npm install -g sf-pi`
- **Node.js** >= 26.1.0
- **Git** installed and on PATH
- **VS Code** >= 1.95.0
## Quick Start
1. Install SF: `npm install -g sf-pi`
2. Install this extension
3. Open a project folder in VS Code
4. Click the **SF icon** in the Activity Bar (left sidebar)
5. Click **Start Agent** or run `Ctrl+Shift+P` > **SF: Start Agent**
6. Start chatting with `@sf` in Chat or click **Auto** in the sidebar
---
## Features
### Sidebar Dashboard
Click the **SF 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 SF'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 (`@sf`)
Use `@sf` in VS Code Chat (`Cmd+Shift+I`) to talk to the agent:
```
@sf refactor the auth module to use JWT
@sf /sf autonomous
@sf 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 **"SF 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 SF 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` > SF: 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 SF** | 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` > **SF: 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 `/sf` 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 |
|---------|----------|-------------|
| **SF: Start Agent** | | Connect to the SF agent |
| **SF: Stop Agent** | | Disconnect the agent |
| **SF: New Session** | `Cmd+Shift+G` `Cmd+Shift+N` | Start a fresh conversation |
| **SF: Send Message** | `Cmd+Shift+G` `Cmd+Shift+P` | Send a message to the agent |
| **SF: Abort** | `Cmd+Shift+G` `Cmd+Shift+A` | Interrupt the current operation |
| **SF: Steer Agent** | `Cmd+Shift+G` `Cmd+Shift+I` | Steering message mid-operation |
| **SF: Switch Model** | | Pick a model from QuickPick |
| **SF: Cycle Model** | `Cmd+Shift+G` `Cmd+Shift+M` | Rotate to the next model |
| **SF: Set Thinking Level** | | Choose off / low / medium / high |
| **SF: Cycle Thinking** | `Cmd+Shift+G` `Cmd+Shift+T` | Rotate through thinking levels |
| **SF: Compact Context** | | Trigger context compaction |
| **SF: Export HTML** | | Save session as HTML |
| **SF: Session Stats** | | Display token usage and cost |
| **SF: Run Bash** | | Execute a shell command |
| **SF: List Commands** | | Browse slash commands |
| **SF: Set Session Name** | | Rename current session |
| **SF: Copy Last Response** | | Copy to clipboard |
| **SF: Switch Session** | | Load a different session |
| **SF: Show History** | | Open conversation viewer |
| **SF: Fork Session** | | Fork from a previous message |
| **SF: Fix Problems in File** | | Send file diagnostics to agent |
| **SF: Fix All Problems** | | Send workspace errors to agent |
| **SF: Commit Agent Changes** | | Git commit modified files |
| **SF: Create Branch** | | Create branch for agent work |
| **SF: Show Agent Diff** | | View git diff |
| **SF: Accept All Changes** | | Accept all SCM changes |
| **SF: Discard All Changes** | | Revert all agent modifications |
| **SF: Select Approval Mode** | | Choose auto-approve/ask/plan-only |
| **SF: Cycle Approval Mode** | | Rotate through approval modes |
| **SF: Code Lens** actions | | Ask, Refactor, Find Bugs, Tests |
> On Windows/Linux, replace `Cmd` with `Ctrl`.
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `sf.binaryPath` | `"sf"` | Path to the SF binary |
| `sf.autoStart` | `false` | Start agent on extension activation |
| `sf.autoCompaction` | `true` | Automatic context compaction |
| `sf.codeLens` | `true` | Code lens above functions/classes |
| `sf.showProgressNotifications` | `false` | Progress notification (off — Chat shows progress) |
| `sf.activityFeedMaxItems` | `100` | Max items in Activity feed |
| `sf.showContextWarning` | `true` | Warn when context exceeds threshold |
| `sf.contextWarningThreshold` | `80` | Context % that triggers warning |
| `sf.approvalMode` | `"auto-approve"` | Agent permission mode |
## How It Works
The extension spawns `sf --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
- [SF Documentation](https://github.com/singularity-forge/sf-run/tree/main/docs)
- [Getting Started](https://github.com/singularity-forge/sf-run/blob/main/docs/getting-started.md)
- [Issue Tracker](https://github.com/singularity-forge/sf-run/issues)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,418 +0,0 @@
{
"name": "sf-2",
"displayName": "SF-2",
"description": "VS Code integration for the SF-2 coding agent — sidebar dashboard, @sf chat participant, activity feed, conversation history, code lens, session forking, slash command completion, workflow controls, and 33 commands",
"publisher": "FluxLabs",
"version": "0.3.0",
"icon": "logo.jpg",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/singularity-forge/sf-run"
},
"homepage": "https://github.com/singularity-forge/sf-run/blob/main/vscode-extension/README.md",
"bugs": {
"url": "https://github.com/singularity-forge/sf-run/issues"
},
"keywords": [
"ai",
"agent",
"coding",
"sf",
"chat",
"automation",
"claude",
"openai",
"llm"
],
"galleryBanner": {
"color": "#1a1a2e",
"theme": "dark"
},
"extensionKind": [
"workspace"
],
"engines": {
"node": ">=26.1.0",
"vscode": "^1.95.0"
},
"categories": [
"AI",
"Chat"
],
"activationEvents": [
"onStartupFinished"
],
"main": "dist/extension.js",
"contributes": {
"commands": [
{
"command": "sf.start",
"title": "SF: Start Agent"
},
{
"command": "sf.stop",
"title": "SF: Stop Agent"
},
{
"command": "sf.newSession",
"title": "SF: New Session"
},
{
"command": "sf.sendMessage",
"title": "SF: Send Message"
},
{
"command": "sf.cycleModel",
"title": "SF: Cycle Model"
},
{
"command": "sf.cycleThinking",
"title": "SF: Cycle Thinking Level"
},
{
"command": "sf.compact",
"title": "SF: Compact Context"
},
{
"command": "sf.abort",
"title": "SF: Abort Current Operation"
},
{
"command": "sf.exportHtml",
"title": "SF: Export Conversation as HTML"
},
{
"command": "sf.sessionStats",
"title": "SF: Show Session Stats"
},
{
"command": "sf.runBash",
"title": "SF: Run Bash Command"
},
{
"command": "sf.switchModel",
"title": "SF: Switch Model"
},
{
"command": "sf.setThinking",
"title": "SF: Set Thinking Level"
},
{
"command": "sf.steer",
"title": "SF: Steer Agent"
},
{
"command": "sf.listCommands",
"title": "SF: List Available Commands"
},
{
"command": "sf.toggleAutoRetry",
"title": "SF: Toggle Auto-Retry"
},
{
"command": "sf.abortRetry",
"title": "SF: Abort Retry"
},
{
"command": "sf.setSessionName",
"title": "SF: Set Session Name"
},
{
"command": "sf.copyLastResponse",
"title": "SF: Copy Last Response"
},
{
"command": "sf.switchSession",
"title": "SF: Switch Session"
},
{
"command": "sf.refreshSessions",
"title": "SF: Refresh Sessions",
"icon": "$(refresh)"
},
{
"command": "sf.clearFileDecorations",
"title": "SF: Clear File Decorations"
},
{
"command": "sf.showHistory",
"title": "SF: Show Conversation History"
},
{
"command": "sf.askAboutSymbol",
"title": "SF: Ask About Symbol"
},
{
"command": "sf.clearActivity",
"title": "SF: Clear Activity Feed",
"icon": "$(clear-all)"
},
{
"command": "sf.forkSession",
"title": "SF: Fork Session"
},
{
"command": "sf.toggleSteeringMode",
"title": "SF: Toggle Steering Mode"
},
{
"command": "sf.toggleFollowUpMode",
"title": "SF: Toggle Follow-Up Mode"
},
{
"command": "sf.refactorSymbol",
"title": "SF: Refactor Symbol"
},
{
"command": "sf.findBugsSymbol",
"title": "SF: Find Bugs in Symbol"
},
{
"command": "sf.generateTestsSymbol",
"title": "SF: Generate Tests for Symbol"
},
{
"command": "sf.acceptAllChanges",
"title": "SF: Accept All Agent Changes",
"icon": "$(check-all)"
},
{
"command": "sf.discardAllChanges",
"title": "SF: Discard All Agent Changes",
"icon": "$(discard)"
},
{
"command": "sf.acceptFileChanges",
"title": "Accept Changes",
"icon": "$(check)"
},
{
"command": "sf.discardFileChanges",
"title": "Discard Changes",
"icon": "$(discard)"
},
{
"command": "sf.restoreCheckpoint",
"title": "SF: Restore Checkpoint"
},
{
"command": "sf.fixProblemsInFile",
"title": "SF: Fix Problems in File"
},
{
"command": "sf.fixAllProblems",
"title": "SF: Fix All Problems"
},
{
"command": "sf.clearDiagnostics",
"title": "SF: Clear Agent Diagnostics"
},
{
"command": "sf.commitAgentChanges",
"title": "SF: Commit Agent Changes"
},
{
"command": "sf.createAgentBranch",
"title": "SF: Create Branch for Agent Work"
},
{
"command": "sf.showAgentDiff",
"title": "SF: Show Agent Diff"
},
{
"command": "sf.clearPlan",
"title": "SF: Clear Plan View",
"icon": "$(clear-all)"
},
{
"command": "sf.cycleApprovalMode",
"title": "SF: Cycle Approval Mode"
},
{
"command": "sf.selectApprovalMode",
"title": "SF: Select Approval Mode"
}
],
"keybindings": [
{
"command": "sf.newSession",
"key": "ctrl+shift+g ctrl+shift+n",
"mac": "cmd+shift+g cmd+shift+n"
},
{
"command": "sf.cycleModel",
"key": "ctrl+shift+g ctrl+shift+m",
"mac": "cmd+shift+g cmd+shift+m"
},
{
"command": "sf.cycleThinking",
"key": "ctrl+shift+g ctrl+shift+t",
"mac": "cmd+shift+g cmd+shift+t"
},
{
"command": "sf.abort",
"key": "ctrl+shift+g ctrl+shift+a",
"mac": "cmd+shift+g cmd+shift+a"
},
{
"command": "sf.steer",
"key": "ctrl+shift+g ctrl+shift+i",
"mac": "cmd+shift+g cmd+shift+i"
},
{
"command": "sf.sendMessage",
"key": "ctrl+shift+g ctrl+shift+p",
"mac": "cmd+shift+g cmd+shift+p"
}
],
"viewsContainers": {
"activitybar": [
{
"id": "sf",
"title": "SF",
"icon": "$(hubot)"
}
]
},
"views": {
"sf": [
{
"type": "webview",
"id": "sf-sidebar",
"name": "SF Agent"
},
{
"id": "sf-sessions",
"name": "Sessions"
},
{
"id": "sf-activity",
"name": "Activity"
}
]
},
"menus": {
"view/title": [
{
"command": "sf.refreshSessions",
"when": "view == sf-sessions",
"group": "navigation"
},
{
"command": "sf.clearActivity",
"when": "view == sf-activity",
"group": "navigation"
}
],
"scm/title": [
{
"command": "sf.acceptAllChanges",
"group": "navigation",
"when": "scmProvider == sf"
},
{
"command": "sf.discardAllChanges",
"group": "navigation",
"when": "scmProvider == sf"
}
],
"scm/resourceState/context": [
{
"command": "sf.acceptFileChanges",
"group": "inline",
"when": "scmProvider == sf"
},
{
"command": "sf.discardFileChanges",
"group": "inline",
"when": "scmProvider == sf"
}
]
},
"chatParticipants": [
{
"id": "sf.agent",
"name": "sf",
"fullName": "SF Agent",
"description": "SF-2 coding agent",
"isSticky": true
}
],
"configuration": {
"title": "SF",
"properties": {
"sf.binaryPath": {
"type": "string",
"default": "sf",
"description": "Path to the SF binary"
},
"sf.autoStart": {
"type": "boolean",
"default": false,
"description": "Automatically start the SF agent when the extension activates"
},
"sf.autoCompaction": {
"type": "boolean",
"default": true,
"description": "Enable automatic context compaction"
},
"sf.codeLens": {
"type": "boolean",
"default": true,
"description": "Show 'Ask SF' code lens above functions and classes"
},
"sf.showProgressNotifications": {
"type": "boolean",
"default": false,
"description": "Show progress notification while the agent is working"
},
"sf.activityFeedMaxItems": {
"type": "number",
"default": 100,
"minimum": 10,
"maximum": 500,
"description": "Maximum number of items shown in the Activity feed"
},
"sf.showContextWarning": {
"type": "boolean",
"default": true,
"description": "Warn when context window usage exceeds the threshold"
},
"sf.contextWarningThreshold": {
"type": "number",
"default": 80,
"minimum": 50,
"maximum": 95,
"description": "Context window usage percentage that triggers a warning"
},
"sf.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"
}
}
}
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"package": "vsce package",
"publish": "vsce publish"
},
"devDependencies": {
"@types/vscode": "^1.95.0",
"@vscode/vsce": "^3.7.1",
"typescript": "^5.7.0"
}
}

View file

@ -1,236 +0,0 @@
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-client.js";
interface ActivityItem {
id: number;
type: "tool" | "agent";
label: string;
detail: string;
icon: vscode.ThemeIcon;
timestamp: number;
duration?: number;
filePath?: string;
status: "running" | "success" | "error";
}
const TOOL_ICONS: Record<string, string> = {
Read: "file",
Write: "new-file",
Edit: "edit",
Bash: "terminal",
Grep: "search",
Glob: "file-directory",
Agent: "organization",
};
function toolSummary(
toolName: string,
toolInput: Record<string, unknown>,
): { label: string; filePath?: string } {
const name = toolName ?? "Unknown";
switch (name) {
case "Read": {
const p = String(toolInput?.file_path ?? toolInput?.path ?? "");
const short = p.split(/[\\/]/).pop() ?? p;
return { label: `Read ${short}`, filePath: p || undefined };
}
case "Write": {
const p = String(toolInput?.file_path ?? "");
const short = p.split(/[\\/]/).pop() ?? p;
return { label: `Write ${short}`, filePath: p || undefined };
}
case "Edit": {
const p = String(toolInput?.file_path ?? "");
const short = p.split(/[\\/]/).pop() ?? p;
return { label: `Edit ${short}`, filePath: p || undefined };
}
case "Bash": {
const cmd = String(toolInput?.command ?? "").slice(0, 60);
return { label: `Bash: ${cmd}` };
}
case "Grep": {
const pat = String(toolInput?.pattern ?? "").slice(0, 40);
return { label: `Grep: ${pat}` };
}
case "Glob": {
const pat = String(toolInput?.pattern ?? "").slice(0, 40);
return { label: `Glob: ${pat}` };
}
default:
return { label: name };
}
}
/**
* TreeDataProvider that shows real-time tool executions from the SF agent.
* Listens to tool_execution_start/end and agent_start/end events.
*/
export class SfActivityFeedProvider
implements vscode.TreeDataProvider<ActivityItem>, vscode.Disposable
{
public static readonly viewId = "sf-activity";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private items: ActivityItem[] = [];
private nextId = 0;
private runningTools = new Map<string, number>(); // toolUseId -> item id
private maxItems: number;
private disposables: vscode.Disposable[] = [];
constructor(readonly client: SfClient) {
this.maxItems = vscode.workspace
.getConfiguration("sf")
.get<number>("activityFeedMaxItems", 100);
this.disposables.push(
this._onDidChangeTreeData,
client.onEvent((evt) => this.handleEvent(evt)),
client.onConnectionChange((connected) => {
if (!connected) {
this.runningTools.clear();
}
this._onDidChangeTreeData.fire();
}),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("sf.activityFeedMaxItems")) {
this.maxItems = vscode.workspace
.getConfiguration("sf")
.get<number>("activityFeedMaxItems", 100);
}
}),
);
}
getTreeItem(element: ActivityItem): vscode.TreeItem {
const item = new vscode.TreeItem(
element.label,
vscode.TreeItemCollapsibleState.None,
);
item.iconPath = element.icon;
item.description =
element.duration !== undefined
? `${element.duration}ms`
: element.status === "running"
? "running..."
: "";
item.tooltip = `${element.detail}\n${new Date(element.timestamp).toLocaleTimeString()}`;
if (element.filePath) {
item.command = {
command: "vscode.open",
title: "Open File",
arguments: [vscode.Uri.file(element.filePath)],
};
}
return item;
}
getChildren(): ActivityItem[] {
// Show newest first
return [...this.items].reverse();
}
clear(): void {
this.items = [];
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": {
this.addItem({
type: "agent",
label: "Agent started",
detail: "Agent began processing",
icon: new vscode.ThemeIcon(
"play",
new vscode.ThemeColor("testing.iconPassed"),
),
status: "running",
});
break;
}
case "agent_end": {
this.addItem({
type: "agent",
label: "Agent finished",
detail: "Agent completed processing",
icon: new vscode.ThemeIcon(
"check",
new vscode.ThemeColor("testing.iconPassed"),
),
status: "success",
});
break;
}
case "tool_execution_start": {
const toolName = String(evt.toolName ?? "");
const toolInput = (evt.toolInput ?? {}) as Record<string, unknown>;
const toolUseId = String(evt.toolUseId ?? "");
const { label, filePath } = toolSummary(toolName, toolInput);
const iconName = TOOL_ICONS[toolName] ?? "tools";
const id = this.addItem({
type: "tool",
label,
detail: `Tool: ${toolName}`,
icon: new vscode.ThemeIcon(
iconName,
new vscode.ThemeColor("charts.yellow"),
),
status: "running",
filePath,
});
if (toolUseId) {
this.runningTools.set(toolUseId, id);
}
break;
}
case "tool_execution_end": {
const toolUseId = String(evt.toolUseId ?? "");
const itemId = this.runningTools.get(toolUseId);
if (itemId !== undefined) {
this.runningTools.delete(toolUseId);
const item = this.items.find((i) => i.id === itemId);
if (item) {
const isError = evt.error === true || evt.isError === true;
item.status = isError ? "error" : "success";
item.duration = Date.now() - item.timestamp;
item.icon = new vscode.ThemeIcon(
isError ? "error" : "check",
new vscode.ThemeColor(
isError ? "testing.iconFailed" : "testing.iconPassed",
),
);
this._onDidChangeTreeData.fire();
}
}
break;
}
}
}
private addItem(partial: Omit<ActivityItem, "id" | "timestamp">): number {
const id = this.nextId++;
this.items.push({ ...partial, id, timestamp: Date.now() });
// Evict old items
while (this.items.length > this.maxItems) {
this.items.shift();
}
this._onDidChangeTreeData.fire();
return id;
}
}

View file

@ -1,90 +0,0 @@
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-client.js";
/**
* Routes the SF agent's Bash tool output to a dedicated VS Code terminal panel.
* Shows streaming output from tool_execution_update events in real time.
*/
export class SfBashTerminal implements vscode.Disposable {
private terminal: vscode.Terminal | undefined;
private writeEmitter: vscode.EventEmitter<string> | undefined;
private disposables: vscode.Disposable[] = [];
constructor(client: SfClient) {
this.disposables.push(
client.onEvent((evt: AgentEvent) => this.handleEvent(evt)),
client.onConnectionChange((connected) => {
if (!connected) {
this.close();
}
}),
);
}
private getOrCreateTerminal(): {
terminal: vscode.Terminal;
writeEmitter: vscode.EventEmitter<string>;
} {
if (!this.terminal || this.terminal.exitStatus !== undefined) {
this.writeEmitter?.dispose();
this.writeEmitter = new vscode.EventEmitter<string>();
const emitter = this.writeEmitter;
const pty: vscode.Pseudoterminal = {
onDidWrite: emitter.event,
open: () => {},
close: () => {
this.terminal = undefined;
},
};
this.terminal = vscode.window.createTerminal({ name: "SF Agent", pty });
}
return { terminal: this.terminal, writeEmitter: this.writeEmitter! };
}
private handleEvent(evt: AgentEvent): void {
switch (evt.type) {
case "tool_execution_start": {
if (evt.toolName !== "Bash") {
break;
}
const cmd = (evt.toolInput as Record<string, unknown> | undefined)
?.command as string | undefined;
const { terminal, writeEmitter } = this.getOrCreateTerminal();
terminal.show(true); // preserve editor focus
writeEmitter.fire(`\x1b[90m$ ${cmd ?? ""}\x1b[0m\r\n`);
break;
}
case "tool_execution_update": {
if (evt.toolName !== "Bash" || !this.writeEmitter) {
break;
}
const partial = evt.partialResult as string | undefined;
if (partial) {
this.writeEmitter.fire(partial.replace(/\n/g, "\r\n"));
}
break;
}
case "tool_execution_end": {
if (evt.toolName !== "Bash" || !this.writeEmitter) {
break;
}
this.writeEmitter.fire("\r\n");
break;
}
}
}
close(): void {
this.terminal?.dispose();
this.terminal = undefined;
this.writeEmitter?.dispose();
this.writeEmitter = undefined;
}
dispose(): void {
this.close();
for (const d of this.disposables) {
d.dispose();
}
}
}

View file

@ -1,307 +0,0 @@
import * as fs from "node:fs";
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-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 SF agent. Stores original file content
* before the agent modifies it, enabling diff views, SCM integration,
* and checkpoint/rollback functionality.
*/
export class SfChangeTracker 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(readonly client: SfClient) {
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

@ -1,359 +0,0 @@
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-client.js";
/**
* Registers the @sf chat participant that forwards messages to the
* SF RPC client and streams tool execution events back to the chat.
*/
export function registerChatParticipant(
_context: vscode.ExtensionContext,
client: SfClient,
): vscode.Disposable {
const participant = vscode.chat.createChatParticipant(
"sf.agent",
async (
request: vscode.ChatRequest,
_chatContext: vscode.ChatContext,
response: vscode.ChatResponseStream,
token: vscode.CancellationToken,
) => {
// Auto-start the agent if not connected
if (!client.isConnected) {
response.progress("Starting SF agent...");
try {
await client.start();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
response.markdown(
`**Failed to start SF agent:** ${msg}\n\nMake sure \`sf\` is installed (\`npm install -g sf-run\`) and try again.`,
);
return;
}
}
// Build the full message, injecting any #file references
let message = request.prompt.trim();
if (!message) {
response.markdown("Please provide a message.");
return;
}
const fileContext = await buildFileContext(request);
if (fileContext) {
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;
let totalOutputTokens = 0;
const filesWritten: string[] = [];
const filesRead: string[] = [];
const eventHandler = (event: AgentEvent) => {
switch (event.type) {
case "agent_start":
response.progress("SF is working...");
break;
case "tool_execution_start": {
const toolName = event.toolName as string;
const toolInput = event.toolInput as
| Record<string, unknown>
| undefined;
const detail = describeToolCall(toolName, toolInput);
response.progress(detail);
// Track file paths for anchors
if (toolInput?.file_path) {
const fp = String(toolInput.file_path);
if (toolName === "Write" || toolName === "Edit") {
if (!filesWritten.includes(fp)) filesWritten.push(fp);
} else if (toolName === "Read") {
if (!filesRead.includes(fp)) filesRead.push(fp);
}
}
break;
}
case "message_update": {
const assistantEvent = event.assistantMessageEvent as
| Record<string, unknown>
| undefined;
if (!assistantEvent) break;
if (assistantEvent.type === "text_delta") {
const delta = assistantEvent.delta as string | undefined;
if (delta) {
response.markdown(delta);
}
} else if (assistantEvent.type === "thinking_delta") {
// Thinking shown inline — prefix with italic so it's visually distinct
const delta = assistantEvent.delta as string | undefined;
if (delta) {
response.markdown(`*${delta}*`);
}
}
break;
}
case "message_end": {
const usage = event.usage as
| { inputTokens?: number; outputTokens?: number }
| undefined;
if (usage) {
if (usage.inputTokens) totalInputTokens += usage.inputTokens;
if (usage.outputTokens) totalOutputTokens += usage.outputTokens;
}
break;
}
case "agent_end":
agentDone = true;
break;
}
};
const subscription = client.onEvent(eventHandler);
token.onCancellationRequested(() => {
client.abort().catch(() => {});
});
try {
await client.sendPrompt(message);
// Wait for agent_end or cancellation
await new Promise<void>((resolve) => {
if (agentDone) {
resolve();
return;
}
const checkDone = client.onEvent((evt) => {
if (evt.type === "agent_end") {
checkDone.dispose();
resolve();
}
});
token.onCancellationRequested(() => {
checkDone.dispose();
resolve();
});
});
// Show clickable file anchors for written files
if (filesWritten.length > 0) {
response.markdown("\n\n**Files changed:**");
for (const fp of filesWritten) {
const uri = resolveFileUri(fp);
if (uri) {
response.anchor(uri, fp);
response.markdown(" ");
}
}
}
// Token usage summary
if (totalInputTokens > 0 || totalOutputTokens > 0) {
response.markdown(
`\n\n---\n*${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out tokens*`,
);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
response.markdown(`\n**Error:** ${errorMessage}`);
} finally {
subscription.dispose();
}
},
);
participant.iconPath = new vscode.ThemeIcon("hubot");
// Follow-up suggestions after each response
participant.followupProvider = {
provideFollowups: (_result, _context, _token) => {
return [
{
prompt: "/sf status",
label: "$(info) Check status",
title: "Check project status",
},
{
prompt: "/sf autonomous",
label: "$(rocket) Run autonomous",
title: "Run autonomous mode",
},
{
prompt: "/sf capture",
label: "$(note) Capture a thought",
title: "Capture a thought mid-session",
},
];
},
};
return participant;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Build a file context block from any #file references in the chat request.
*/
async function buildFileContext(
request: vscode.ChatRequest,
): Promise<string | null> {
if (!request.references || request.references.length === 0) {
return null;
}
const parts: string[] = [];
for (const ref of request.references) {
if (ref.value instanceof vscode.Uri) {
try {
const bytes = await vscode.workspace.fs.readFile(ref.value);
const content = Buffer.from(bytes).toString("utf-8");
const relativePath = vscode.workspace.asRelativePath(ref.value);
parts.push(`File: ${relativePath}\n\`\`\`\n${content}\n\`\`\``);
} catch {
// Skip unreadable files
}
} else if (ref.value instanceof vscode.Location) {
try {
const doc = await vscode.workspace.openTextDocument(ref.value.uri);
const text = doc.getText(ref.value.range);
const relativePath = vscode.workspace.asRelativePath(ref.value.uri);
const { start, end } = ref.value.range;
parts.push(
`File: ${relativePath} (lines ${start.line + 1}${end.line + 1})\n\`\`\`\n${text}\n\`\`\``,
);
} catch {
// Skip unreadable ranges
}
}
}
return parts.length > 0 ? parts.join("\n\n") : null;
}
/**
* Produce a human-readable progress label for a tool call.
*/
function describeToolCall(
toolName: string,
input?: Record<string, unknown>,
): string {
if (!input) {
return `Running: ${toolName}`;
}
switch (toolName) {
case "Read":
return `Reading: ${shortenPath(String(input.file_path ?? ""))}`;
case "Write":
return `Writing: ${shortenPath(String(input.file_path ?? ""))}`;
case "Edit":
return `Editing: ${shortenPath(String(input.file_path ?? ""))}`;
case "Bash": {
const cmd = String(input.command ?? "");
return `$ ${cmd.length > 80 ? cmd.slice(0, 77) + "…" : cmd}`;
}
case "Glob":
return `Searching: ${input.pattern ?? ""}`;
case "Grep":
return `Grep: ${input.pattern ?? ""}`;
case "WebSearch":
return `Searching web: ${String(input.query ?? "").slice(0, 60)}`;
case "WebFetch":
return `Fetching: ${String(input.url ?? "").slice(0, 60)}`;
default:
return `Running: ${toolName}`;
}
}
/**
* Shorten an absolute path to just the last 23 segments for display.
*/
function shortenPath(fp: string): string {
const parts = fp.replace(/\\/g, "/").split("/");
return parts.slice(-3).join("/");
}
/**
* Attempt to resolve a file path string to a VS Code URI.
*/
function resolveFileUri(fp: string): vscode.Uri | null {
try {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
return null;
}
// Absolute path
if (fp.startsWith("/") || /^[A-Za-z]:[\\/]/.test(fp)) {
return vscode.Uri.file(fp);
}
// Relative path — resolve against first workspace folder
return vscode.Uri.joinPath(workspaceFolders[0].uri, fp);
} catch {
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

@ -1,61 +0,0 @@
import * as vscode from "vscode";
import type { Checkpoint, SfChangeTracker } 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 SfCheckpointProvider
implements vscode.TreeDataProvider<Checkpoint>, vscode.Disposable
{
public static readonly viewId = "sf-checkpoints";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private disposables: vscode.Disposable[] = [];
constructor(private readonly tracker: SfChangeTracker) {
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: "sf.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

@ -1,162 +0,0 @@
import * as vscode from "vscode";
import type { SfClient } from "./sf-client.js";
/**
* Patterns that identify the start of a named function, class, or method
* declaration in common languages. Each entry captures the symbol name in
* capture group 1.
*/
const SYMBOL_PATTERNS: { languages: string[]; regex: RegExp }[] = [
{
// TypeScript / JavaScript: function foo(...) | async function foo(...)
languages: [
"typescript",
"typescriptreact",
"javascript",
"javascriptreact",
],
regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/,
},
{
// TypeScript / JavaScript: class Foo
languages: [
"typescript",
"typescriptreact",
"javascript",
"javascriptreact",
],
regex: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/,
},
{
// TypeScript / JavaScript: method declarations inside a class
// foo(...) { | async foo(...) { | private foo(...): T {
languages: [
"typescript",
"typescriptreact",
"javascript",
"javascriptreact",
],
regex:
/^\s*(?:(?:public|private|protected|static|async|readonly)\s+)*(\w+)\s*\(/,
},
{
// Python: def foo( | async def foo(
languages: ["python"],
regex: /^\s*(?:async\s+)?def\s+(\w+)\s*\(/,
},
{
// Python: class Foo
languages: ["python"],
regex: /^\s*class\s+(\w+)/,
},
{
// Go: func foo( | func (r Receiver) foo(
languages: ["go"],
regex: /^\s*func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/,
},
{
// Rust: fn foo( | pub fn foo( | async fn foo(
languages: ["rust"],
regex: /^\s*(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?fn\s+(\w+)\s*[(<]/,
},
];
/**
* CodeLensProvider that adds an "Ask SF" lens above named function and class
* declarations. Clicking the lens sends a brief explanation request to the SF
* agent for that specific symbol.
*/
export class SfCodeLensProvider
implements vscode.CodeLensProvider, vscode.Disposable
{
private readonly _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
private disposables: vscode.Disposable[] = [];
constructor(readonly client: SfClient) {
this.disposables.push(
this._onDidChangeCodeLenses,
client.onConnectionChange(() => this._onDidChangeCodeLenses.fire()),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("sf.codeLens")) {
this._onDidChangeCodeLenses.fire();
}
}),
);
}
provideCodeLenses(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): vscode.CodeLens[] {
const lenses: vscode.CodeLens[] = [];
if (
!vscode.workspace.getConfiguration("sf").get<boolean>("codeLens", true)
) {
return lenses;
}
const langId = document.languageId;
const patterns = SYMBOL_PATTERNS.filter((p) =>
p.languages.includes(langId),
);
if (patterns.length === 0) {
return lenses;
}
const fileName =
document.fileName.split(/[\\/]/).pop() ?? document.fileName;
const seen = new Set<number>();
for (let i = 0; i < document.lineCount; i++) {
const text = document.lineAt(i).text;
for (const { regex } of patterns) {
const match = regex.exec(text);
if (match && match[1] && !seen.has(i)) {
seen.add(i);
const symbolName = match[1];
const range = new vscode.Range(i, 0, i, text.length);
const args = [symbolName, fileName, i + 1];
lenses.push(
new vscode.CodeLens(range, {
title: "$(hubot) Ask SF",
tooltip: `Ask SF to explain ${symbolName}`,
command: "sf.askAboutSymbol",
arguments: args,
}),
new vscode.CodeLens(range, {
title: "$(pencil) Refactor",
tooltip: `Refactor ${symbolName}`,
command: "sf.refactorSymbol",
arguments: args,
}),
new vscode.CodeLens(range, {
title: "$(bug) Find Bugs",
tooltip: `Review ${symbolName} for bugs`,
command: "sf.findBugsSymbol",
arguments: args,
}),
new vscode.CodeLens(range, {
title: "$(beaker) Tests",
tooltip: `Generate tests for ${symbolName}`,
command: "sf.generateTestsSymbol",
arguments: args,
}),
);
}
}
}
return lenses;
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}

View file

@ -1,446 +0,0 @@
import * as vscode from "vscode";
import type { SfClient } from "./sf-client.js";
interface ContentBlock {
type: string;
text?: string;
name?: string;
input?: Record<string, unknown>;
content?: string | ContentBlock[];
[key: string]: unknown;
}
interface ConversationMessage {
role: "user" | "assistant" | "system";
content: string | ContentBlock[];
}
/**
* Webview panel that displays the full conversation history for the
* current SF session using the get_messages RPC call. Shows tool calls,
* thinking blocks, search/filter, and fork-from-here actions.
*/
export class SfConversationHistoryPanel implements vscode.Disposable {
private static currentPanel: SfConversationHistoryPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private readonly client: SfClient;
private disposables: vscode.Disposable[] = [];
static createOrShow(
extensionUri: vscode.Uri,
client: SfClient,
): SfConversationHistoryPanel {
const column =
vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
if (SfConversationHistoryPanel.currentPanel) {
SfConversationHistoryPanel.currentPanel.panel.reveal(column);
void SfConversationHistoryPanel.currentPanel.refresh();
return SfConversationHistoryPanel.currentPanel;
}
const panel = vscode.window.createWebviewPanel(
"sf-history",
"SF Conversation History",
column,
{
enableScripts: true,
retainContextWhenHidden: true,
},
);
SfConversationHistoryPanel.currentPanel = new SfConversationHistoryPanel(
panel,
extensionUri,
client,
);
void SfConversationHistoryPanel.currentPanel.refresh();
return SfConversationHistoryPanel.currentPanel;
}
private constructor(
panel: vscode.WebviewPanel,
_extensionUri: vscode.Uri,
client: SfClient,
) {
this.panel = panel;
this.client = client;
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.onDidReceiveMessage(
async (msg: { command: string; entryId?: string }) => {
if (msg.command === "refresh") {
await this.refresh();
} else if (msg.command === "fork" && msg.entryId) {
try {
const result = await this.client.forkSession(msg.entryId);
if (!result.cancelled) {
vscode.window.showInformationMessage(
"Session forked successfully.",
);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Fork failed: ${errMsg}`);
}
}
},
null,
this.disposables,
);
}
async refresh(): Promise<void> {
if (!this.client.isConnected) {
this.panel.webview.html = this.getHtml([], "Not connected to SF agent.");
return;
}
try {
const raw = await this.client.getMessages();
this.panel.webview.html = this.getHtml(raw as ConversationMessage[]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.panel.webview.html = this.getHtml(
[],
`Error loading messages: ${msg}`,
);
}
}
dispose(): void {
SfConversationHistoryPanel.currentPanel = undefined;
this.panel.dispose();
for (const d of this.disposables) {
d.dispose();
}
}
private getHtml(
messages: ConversationMessage[],
errorMessage?: string,
): string {
const nonce = getNonce();
const visibleMessages = messages.filter(
(m) => m.role === "user" || m.role === "assistant",
);
const renderedMessages = visibleMessages
.map((msg, idx) => {
const isUser = msg.role === "user";
const blocks = renderContentBlocks(msg.content);
if (!blocks.trim()) return "";
const entryId = `msg-${idx}`;
const forkBtn = `<button class="fork-btn" data-entry-id="${entryId}" title="Fork from this message">Fork</button>`;
return `<div class="message ${isUser ? "user" : "assistant"}" id="${entryId}">
<div class="role-row">
<span class="role">${isUser ? "You" : "SF"}</span>
${forkBtn}
</div>
<div class="content">${blocks}</div>
</div>`;
})
.filter(Boolean)
.join("\n");
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<style>
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
padding: 16px;
margin: 0;
}
h2 {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.search-input {
flex: 1;
padding: 5px 10px;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border-radius: 2px;
font-size: var(--vscode-font-size);
}
.btn {
padding: 5px 12px;
border: none;
border-radius: 2px;
cursor: pointer;
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
white-space: nowrap;
}
.btn:hover { background: var(--vscode-button-hoverBackground); }
.count {
font-size: 12px;
opacity: 0.6;
white-space: nowrap;
}
.error {
color: var(--vscode-errorForeground);
padding: 10px 12px;
background: var(--vscode-inputValidation-errorBackground);
border-radius: 4px;
margin-bottom: 12px;
}
.empty {
opacity: 0.55;
font-style: italic;
}
.message {
margin-bottom: 14px;
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
}
.message.hidden {
display: none;
}
.role-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 10px;
background: var(--vscode-panel-border);
}
.message.assistant .role-row {
background: var(--vscode-focusBorder);
}
.role {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
opacity: 0.85;
}
.message.assistant .role {
color: var(--vscode-button-foreground);
opacity: 1;
}
.fork-btn {
padding: 1px 6px;
font-size: 10px;
border: 1px solid var(--vscode-foreground);
background: transparent;
color: var(--vscode-foreground);
border-radius: 3px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.message:hover .fork-btn {
opacity: 0.6;
}
.fork-btn:hover {
opacity: 1 !important;
background: var(--vscode-button-secondaryBackground);
}
.content {
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
.tool-block {
margin: 8px 0;
padding: 6px 10px;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
font-size: 12px;
}
.tool-header {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-weight: 600;
opacity: 0.8;
}
.tool-header:hover {
opacity: 1;
}
.tool-body {
display: none;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--vscode-panel-border);
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
opacity: 0.75;
}
.tool-block.expanded .tool-body {
display: block;
}
.thinking-block {
margin: 8px 0;
padding: 6px 10px;
background: var(--vscode-editor-background);
border-left: 3px solid var(--vscode-focusBorder);
border-radius: 2px;
font-size: 12px;
opacity: 0.65;
font-style: italic;
}
.thinking-header {
cursor: pointer;
user-select: none;
font-weight: 600;
}
.thinking-body {
display: none;
margin-top: 4px;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.thinking-block.expanded .thinking-body {
display: block;
}
code {
background: var(--vscode-editor-background);
padding: 1px 4px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 0.92em;
}
</style>
</head>
<body>
<h2>Conversation History</h2>
<div class="toolbar">
<input type="text" class="search-input" id="search" placeholder="Search messages..." />
<button class="btn" id="refresh">Refresh</button>
${visibleMessages.length > 0 ? `<span class="count">${visibleMessages.length} message${visibleMessages.length === 1 ? "" : "s"}</span>` : ""}
</div>
${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ""}
<div id="messages">
${!errorMessage && renderedMessages === "" ? '<div class="empty">No messages in this session.</div>' : renderedMessages}
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
document.getElementById('refresh').addEventListener('click', () => {
vscode.postMessage({ command: 'refresh' });
});
// Search filter
document.getElementById('search').addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
document.querySelectorAll('.message').forEach((el) => {
const text = el.textContent.toLowerCase();
el.classList.toggle('hidden', query && !text.includes(query));
});
});
// Toggle tool/thinking blocks
document.addEventListener('click', (e) => {
const header = e.target.closest('.tool-header, .thinking-header');
if (header) {
header.parentElement.classList.toggle('expanded');
return;
}
const forkBtn = e.target.closest('.fork-btn');
if (forkBtn) {
vscode.postMessage({ command: 'fork', entryId: forkBtn.dataset.entryId });
}
});
</script>
</body>
</html>`;
}
}
function renderContentBlocks(content: string | ContentBlock[]): string {
if (typeof content === "string") return escapeHtml(content);
if (!Array.isArray(content)) return "";
return content
.map((block) => {
if (typeof block === "string") return escapeHtml(block);
switch (block.type) {
case "text":
return escapeHtml(block.text ?? "");
case "thinking":
if (!block.text) return "";
return `<div class="thinking-block">
<div class="thinking-header">Thinking...</div>
<div class="thinking-body">${escapeHtml(block.text)}</div>
</div>`;
case "tool_use":
return `<div class="tool-block">
<div class="tool-header">Tool: ${escapeHtml(block.name ?? "unknown")}</div>
<div class="tool-body">${escapeHtml(JSON.stringify(block.input ?? {}, null, 2))}</div>
</div>`;
case "tool_result": {
const resultText =
typeof block.content === "string"
? block.content
: Array.isArray(block.content)
? block.content
.map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
.join("")
: "";
if (!resultText) return "";
const truncated =
resultText.length > 500
? resultText.slice(0, 500) + "..."
: resultText;
return `<div class="tool-block">
<div class="tool-header">Tool Result</div>
<div class="tool-body">${escapeHtml(truncated)}</div>
</div>`;
}
default:
return "";
}
})
.join("");
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getNonce(): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let nonce = "";
for (let i = 0; i < 32; i++) {
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
}
return nonce;
}

View file

@ -1,160 +0,0 @@
import * as vscode from "vscode";
import type { SfClient } from "./sf-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 SfDiagnosticBridge implements vscode.Disposable {
private readonly collection: vscode.DiagnosticCollection;
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: SfClient) {
this.collection = vscode.languages.createDiagnosticCollection("sf");
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 SF 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 = "SF Agent";
this.collection.set(uri, [...existing, diagnostic]);
}
/** Clear all SF 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";
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,90 +0,0 @@
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-client.js";
/**
* Badges files in the VS Code explorer that SF has written or edited
* during the current session.
*/
export class SfFileDecorationProvider
implements vscode.FileDecorationProvider, vscode.Disposable
{
private readonly _onDidChangeFileDecorations = new vscode.EventEmitter<
vscode.Uri | vscode.Uri[] | undefined
>();
readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event;
private modifiedUris = new Set<string>();
private disposables: vscode.Disposable[] = [];
constructor(readonly client: SfClient) {
this.disposables.push(
this._onDidChangeFileDecorations,
client.onEvent((evt: AgentEvent) => this.handleEvent(evt)),
client.onConnectionChange((connected) => {
if (!connected) {
this.clear();
}
}),
);
}
private handleEvent(evt: AgentEvent): void {
if (evt.type !== "tool_execution_start") {
return;
}
const toolName = evt.toolName as string | undefined;
if (toolName !== "Write" && toolName !== "Edit") {
return;
}
const toolInput = evt.toolInput as Record<string, unknown> | undefined;
const fp = toolInput?.file_path ? String(toolInput.file_path) : undefined;
if (!fp) {
return;
}
const uri = resolveUri(fp);
if (uri) {
this.modifiedUris.add(uri.toString());
this._onDidChangeFileDecorations.fire(uri);
}
}
provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined {
if (this.modifiedUris.has(uri.toString())) {
return {
badge: "G",
tooltip: "Modified by SF",
color: new vscode.ThemeColor(
"gitDecoration.modifiedResourceForeground",
),
};
}
return undefined;
}
clear(): void {
this.modifiedUris.clear();
this._onDidChangeFileDecorations.fire(undefined);
}
dispose(): void {
this.clear();
for (const d of this.disposables) {
d.dispose();
}
}
}
function resolveUri(fp: string): vscode.Uri | null {
try {
if (fp.startsWith("/") || /^[A-Za-z]:[\\/]/.test(fp)) {
return vscode.Uri.file(fp);
}
const folders = vscode.workspace.workspaceFolders;
if (!folders?.length) {
return null;
}
return vscode.Uri.joinPath(folders[0].uri, fp);
} catch {
return null;
}
}

View file

@ -1,131 +0,0 @@
import { execFile } from "node:child_process";
import * as vscode from "vscode";
import type { SfChangeTracker } from "./change-tracker.js";
/**
* Provides git integration for agent changes commit, branch, and diff.
*/
export class SfGitIntegration implements vscode.Disposable {
private disposables: vscode.Disposable[] = [];
constructor(
private readonly tracker: SfChangeTracker,
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]);
// Commit
await this.git(["commit", "-m", message]);
// 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("SF Git Diff");
channel.appendLine("# Agent-modified files (unstaged):");
channel.appendLine(status);
channel.show();
} else {
const channel = vscode.window.createOutputChannel("SF 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) => {
execFile(
"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

@ -1,148 +0,0 @@
import * as vscode from "vscode";
import type { SfChangeTracker } from "./change-tracker.js";
/**
* Provides line-level editor decorations for files modified by the SF agent.
* Shows subtle background highlights on changed lines and gutter icons.
*/
export class SfLineDecorationManager 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: SfChangeTracker) {
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 SF 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 SF 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

@ -1,157 +0,0 @@
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-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 SfPermissionManager 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: SfClient) {
// Load saved mode from configuration
this._mode = vscode.workspace
.getConfiguration("sf")
.get<ApprovalMode>("approvalMode", "auto-approve");
this.disposables.push(
this._onModeChange,
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("sf.approvalMode")) {
this._mode = vscode.workspace
.getConfiguration("sf")
.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("sf")
.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("sf")
.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

@ -1,218 +0,0 @@
import * as vscode from "vscode";
import type { AgentEvent, SfClient } from "./sf-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 SfPlanViewerProvider
implements vscode.TreeDataProvider<PlanStep>, vscode.Disposable
{
public static readonly viewId = "sf-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(readonly client: SfClient) {
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

@ -1,127 +0,0 @@
import * as path from "node:path";
import * as vscode from "vscode";
import type { SfChangeTracker } from "./change-tracker.js";
const SF_ORIGINAL_SCHEME = "sf-original";
/**
* Source Control provider that shows files modified by the SF agent
* in a dedicated "SF Agent" section of the Source Control panel.
* Supports QuickDiff to show before/after diffs, and accept/discard per-file.
*/
export class SfScmProvider implements vscode.Disposable {
private readonly scm: vscode.SourceControl;
private readonly changesGroup: vscode.SourceControlResourceGroup;
private readonly contentProvider: SfOriginalContentProvider;
private disposables: vscode.Disposable[] = [];
constructor(
private readonly tracker: SfChangeTracker,
private readonly workspaceRoot: string,
) {
// Register content provider for original file contents
this.contentProvider = new SfOriginalContentProvider(tracker);
this.disposables.push(
vscode.workspace.registerTextDocumentContentProvider(
SF_ORIGINAL_SCHEME,
this.contentProvider,
),
);
// Create source control instance
this.scm = vscode.scm.createSourceControl(
"sf",
"SF 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: SF_ORIGINAL_SCHEME });
}
return undefined;
},
};
this.scm.inputBox.placeholder = "Describe changes to accept...";
this.scm.acceptInputCommand = {
command: "sf.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 SF Agent`,
light: { iconPath: new vscode.ThemeIcon("edit") },
dark: { iconPath: new vscode.ThemeIcon("edit") },
},
command: {
command: "vscode.diff",
title: "Show Changes",
arguments: [
uri.with({ scheme: SF_ORIGINAL_SCHEME }),
uri,
`${fileName} (SF 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 `sf-original:` URI scheme.
*/
class SfOriginalContentProvider implements vscode.TextDocumentContentProvider {
private readonly _onDidChange = new vscode.EventEmitter<vscode.Uri>();
readonly onDidChange = this._onDidChange.event;
constructor(private readonly tracker: SfChangeTracker) {
tracker.onDidChange((paths) => {
for (const p of paths) {
this._onDidChange.fire(
vscode.Uri.file(p).with({ scheme: SF_ORIGINAL_SCHEME }),
);
}
});
}
provideTextDocumentContent(uri: vscode.Uri): string {
const filePath = uri.with({ scheme: "file" }).fsPath;
return this.tracker.getOriginal(filePath) ?? "";
}
}

View file

@ -1,163 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import type { SfClient } from "./sf-client.js";
export interface SessionItem {
label: string;
sessionFile: string;
timestamp: Date;
sessionId: string;
isCurrent: boolean;
}
/**
* Tree view provider that lists SF session files from the same directory
* as the currently active session.
*/
export class SfSessionTreeProvider
implements vscode.TreeDataProvider<SessionItem>, vscode.Disposable
{
public static readonly viewId = "sf-sessions";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private sessions: SessionItem[] = [];
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: SfClient) {
this.disposables.push(
this._onDidChangeTreeData,
client.onConnectionChange(() => this.refresh()),
);
}
async refresh(): Promise<void> {
this.sessions = await this.loadSessions();
this._onDidChangeTreeData.fire();
}
private async loadSessions(): Promise<SessionItem[]> {
if (!this.client.isConnected) {
return [];
}
try {
const state = await this.client.getState();
this.currentSessionFile = state.sessionFile;
if (!state.sessionFile) {
return [];
}
const sessionDir = path.dirname(state.sessionFile);
const files = fs
.readdirSync(sessionDir)
.filter((f) => f.endsWith(".jsonl"))
.sort()
.reverse(); // newest first
const items: SessionItem[] = [];
for (const file of files) {
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;
}
if (Number.isNaN(timestamp.getTime())) continue;
items.push({
label: formatDate(timestamp),
sessionFile,
timestamp,
sessionId,
isCurrent: sessionFile === state.sessionFile,
});
}
return items;
} catch {
return [];
}
}
getTreeItem(element: SessionItem): vscode.TreeItem {
const item = new vscode.TreeItem(
element.label,
vscode.TreeItemCollapsibleState.None,
);
item.description = element.sessionId.slice(0, 8);
item.tooltip = new vscode.MarkdownString(
`**${element.label}**\n\nID: \`${element.sessionId}\`\n\nFile: \`${element.sessionFile}\``,
);
item.iconPath = new vscode.ThemeIcon(
element.isCurrent ? "comment-discussion" : "history",
element.isCurrent
? new vscode.ThemeColor("terminal.ansiGreen")
: undefined,
);
if (!element.isCurrent) {
item.command = {
command: "sf.switchSession",
title: "Switch to Session",
arguments: [element.sessionFile],
};
}
item.contextValue = element.isCurrent ? "currentSession" : "session";
return item;
}
getChildren(): SessionItem[] {
return this.sessions;
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}
function formatDate(d: Date): string {
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffDays === 0) {
return `Today ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays === 1) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays < 7) {
return d.toLocaleDateString([], {
weekday: "short",
hour: "2-digit",
minute: "2-digit",
});
}
return d.toLocaleDateString([], {
month: "short",
day: "numeric",
year: "numeric",
});
}

View file

@ -1,769 +0,0 @@
import { type ChildProcess, spawn } from "node:child_process";
import * as vscode from "vscode";
/**
* Mirrors the RPC command/response protocol from the SF agent.
* These types are intentionally kept minimal and self-contained so the
* extension has no dependency on the agent packages at runtime.
*/
export type ThinkingLevel = "off" | "low" | "medium" | "high";
export interface RpcSessionState {
model?: { provider: string; id: string; contextWindow?: number };
thinkingLevel: ThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
sessionFile?: string;
sessionId: string;
sessionName?: string;
autoCompactionEnabled: boolean;
messageCount: number;
pendingMessageCount: number;
}
export interface ModelInfo {
provider: string;
id: string;
contextWindow?: number;
reasoning?: boolean;
}
export interface SessionStats {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
totalCost?: number;
messageCount?: number;
turnCount?: number;
duration?: number;
}
export interface BashResult {
stdout: string;
stderr: string;
exitCode: number | null;
}
export interface SlashCommand {
name: string;
description?: string;
source: "extension" | "prompt" | "skill";
location?: "user" | "project" | "path";
path?: string;
}
export interface RpcResponse {
id?: string;
type: "response";
command: string;
success: boolean;
data?: unknown;
error?: string;
}
export interface AgentEvent {
type: string;
[key: string]: unknown;
}
type PendingRequest = {
resolve: (response: RpcResponse) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
/**
* Client that spawns `sf --mode rpc` and communicates via JSON lines
* over stdin/stdout. Emits VS Code events for streaming responses.
*/
export class SfClient implements vscode.Disposable {
private process: ChildProcess | null = null;
private pendingRequests = new Map<string, PendingRequest>();
private requestId = 0;
private buffer = "";
private restartCount = 0;
private restartTimestamps: number[] = [];
private _autoRetryEnabled = false;
private readonly _onEvent = new vscode.EventEmitter<AgentEvent>();
readonly onEvent = this._onEvent.event;
private readonly _onConnectionChange = new vscode.EventEmitter<boolean>();
readonly onConnectionChange = this._onConnectionChange.event;
private readonly _onError = new vscode.EventEmitter<string>();
readonly onError = this._onError.event;
private disposables: vscode.Disposable[] = [];
constructor(
private readonly binaryPath: string,
private readonly cwd: string,
) {
this.disposables.push(
this._onEvent,
this._onConnectionChange,
this._onError,
);
}
get isConnected(): boolean {
return this.process !== null && this.process.exitCode === null;
}
get autoRetryEnabled(): boolean {
return this._autoRetryEnabled;
}
/**
* Spawn the SF agent in RPC mode.
*/
async start(): Promise<void> {
if (this.process) {
return;
}
const proc = spawn(this.binaryPath, ["--mode", "rpc"], {
cwd: this.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
shell: process.platform === "win32",
});
this.process = proc;
this.buffer = "";
proc.stdout?.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString("utf8");
this.drainBuffer();
});
proc.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8").trim();
if (text) {
this._onError.fire(text);
}
});
let startupSettled = false;
const startupResult = new Promise<void>((resolve, reject) => {
const cleanup = () => {
proc.off("spawn", handleSpawn);
proc.off("error", handleStartupError);
};
const handleSpawn = () => {
if (startupSettled) return;
startupSettled = true;
cleanup();
this._onConnectionChange.fire(true);
this.restartCount = 0;
resolve();
};
const handleStartupError = (err: NodeJS.ErrnoException) => {
if (startupSettled) return;
startupSettled = true;
cleanup();
if (this.process === proc) {
this.process = null;
}
const hint =
err.code === "ENOENT"
? ` Make sure SF is installed ("npm install -g sf-run") and set "sf.binaryPath" to the absolute path if it is not on PATH.`
: "";
const message = `Failed to start SF process: ${err.message}.${hint}`;
this._onError.fire(message);
reject(new Error(message));
};
proc.once("spawn", handleSpawn);
proc.once("error", handleStartupError);
});
proc.on("error", (err: NodeJS.ErrnoException) => {
if (!startupSettled) {
return;
}
if (this.process === proc) {
this.process = null;
}
this._onConnectionChange.fire(false);
const hint =
err.code === "ENOENT"
? ` Make sure SF is installed ("npm install -g sf-run") and set "sf.binaryPath" to the absolute path if it is not on PATH.`
: "";
this._onError.fire(`SF process error: ${err.message}.${hint}`);
});
proc.on("exit", (code, signal) => {
if (this.process === proc) {
this.process = null;
}
this.rejectAllPending(
`SF process exited (code=${code}, signal=${signal})`,
);
this._onConnectionChange.fire(false);
if (code !== 0 && signal !== "SIGTERM") {
const now = Date.now();
this.restartTimestamps.push(now);
// Keep only timestamps within the last 60 seconds
this.restartTimestamps = this.restartTimestamps.filter(
(t) => now - t < 60_000,
);
if (this.restartTimestamps.length > 3) {
// Too many crashes within 60s — stop retrying
this._onError.fire(
`SF process crashed ${this.restartTimestamps.length} times within 60s. Not restarting. Use "SF: Start Agent" to retry manually.`,
);
} else if (this.restartCount < 3) {
this.restartCount++;
setTimeout(() => this.start(), 1000 * this.restartCount);
}
}
});
await startupResult;
}
/**
* Stop the SF agent process.
*/
async stop(): Promise<void> {
if (!this.process) {
return;
}
const proc = this.process;
this.process = null;
proc.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
resolve();
}, 2000);
proc.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
this.rejectAllPending("Client stopped");
this._onConnectionChange.fire(false);
}
// =========================================================================
// Prompting
// =========================================================================
/**
* Send a prompt message to the agent.
* Returns once the command is acknowledged; streaming events follow via onEvent.
*/
async sendPrompt(message: string): Promise<void> {
const response = await this.send({ type: "prompt", message });
this.assertSuccess(response);
}
/**
* Interrupt the agent with a steering message while it is streaming.
*/
async steer(message: string): Promise<void> {
const response = await this.send({ type: "steer", message });
this.assertSuccess(response);
}
/**
* Send a follow-up message after the agent has completed.
*/
async followUp(message: string): Promise<void> {
const response = await this.send({ type: "follow_up", message });
this.assertSuccess(response);
}
/**
* Abort current operation.
*/
async abort(): Promise<void> {
const response = await this.send({ type: "abort" });
this.assertSuccess(response);
}
// =========================================================================
// State
// =========================================================================
/**
* Get current session state.
*/
async getState(): Promise<RpcSessionState> {
const response = await this.send({ type: "get_state" });
this.assertSuccess(response);
return response.data as RpcSessionState;
}
// =========================================================================
// Model
// =========================================================================
/**
* Set the active model.
*/
async setModel(provider: string, modelId: string): Promise<void> {
const response = await this.send({ type: "set_model", provider, modelId });
this.assertSuccess(response);
}
/**
* Get available models.
*/
async getAvailableModels(): Promise<ModelInfo[]> {
const response = await this.send({ type: "get_available_models" });
this.assertSuccess(response);
return (response.data as { models: ModelInfo[] }).models;
}
/**
* Cycle through available models.
*/
async cycleModel(): Promise<{
model: ModelInfo;
thinkingLevel: ThinkingLevel;
isScoped: boolean;
} | null> {
const response = await this.send({ type: "cycle_model" });
this.assertSuccess(response);
return response.data as {
model: ModelInfo;
thinkingLevel: ThinkingLevel;
isScoped: boolean;
} | null;
}
// =========================================================================
// Thinking
// =========================================================================
/**
* Set the thinking level explicitly.
*/
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
const response = await this.send({ type: "set_thinking_level", level });
this.assertSuccess(response);
}
/**
* Cycle through thinking levels (off -> low -> medium -> high -> off).
*/
async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> {
const response = await this.send({ type: "cycle_thinking_level" });
this.assertSuccess(response);
return response.data as { level: ThinkingLevel } | null;
}
// =========================================================================
// Compaction
// =========================================================================
/**
* Manually compact the conversation context.
*/
async compact(customInstructions?: string): Promise<unknown> {
const cmd: Record<string, unknown> = { type: "compact" };
if (customInstructions) {
cmd.customInstructions = customInstructions;
}
const response = await this.send(cmd);
this.assertSuccess(response);
return response.data;
}
/**
* Enable or disable automatic compaction.
*/
async setAutoCompaction(enabled: boolean): Promise<void> {
const response = await this.send({ type: "set_auto_compaction", enabled });
this.assertSuccess(response);
}
// =========================================================================
// Retry
// =========================================================================
/**
* Enable or disable automatic retry on failure.
*/
async setAutoRetry(enabled: boolean): Promise<void> {
const response = await this.send({ type: "set_auto_retry", enabled });
this.assertSuccess(response);
this._autoRetryEnabled = enabled;
}
/**
* Abort a pending retry.
*/
async abortRetry(): Promise<void> {
const response = await this.send({ type: "abort_retry" });
this.assertSuccess(response);
}
// =========================================================================
// Bash
// =========================================================================
/**
* Execute a bash command via the agent.
*/
async runBash(command: string): Promise<BashResult> {
const response = await this.send({ type: "bash", command });
this.assertSuccess(response);
return response.data as BashResult;
}
/**
* Abort a running bash command.
*/
async abortBash(): Promise<void> {
const response = await this.send({ type: "abort_bash" });
this.assertSuccess(response);
}
// =========================================================================
// Session
// =========================================================================
/**
* Start a new session.
*/
async newSession(): Promise<void> {
const response = await this.send({ type: "new_session" });
this.assertSuccess(response);
this._autoRetryEnabled = false;
}
/**
* Get session statistics (token counts, cost, etc.).
*/
async getSessionStats(): Promise<SessionStats> {
const response = await this.send({ type: "get_session_stats" });
this.assertSuccess(response);
return response.data as SessionStats;
}
/**
* Export the conversation as HTML.
*/
async exportHtml(outputPath?: string): Promise<{ path: string }> {
const cmd: Record<string, unknown> = { type: "export_html" };
if (outputPath) {
cmd.outputPath = outputPath;
}
const response = await this.send(cmd);
this.assertSuccess(response);
return response.data as { path: string };
}
/**
* Switch to a different session file.
*/
async switchSession(sessionPath: string): Promise<void> {
const response = await this.send({ type: "switch_session", sessionPath });
this.assertSuccess(response);
}
/**
* Set the display name for the current session.
*/
async setSessionName(name: string): Promise<void> {
const response = await this.send({ type: "set_session_name", name });
this.assertSuccess(response);
}
/**
* Get all conversation messages.
*/
async getMessages(): Promise<unknown[]> {
const response = await this.send({ type: "get_messages" });
this.assertSuccess(response);
return (response.data as { messages: unknown[] }).messages;
}
/**
* Get the text of the last assistant response.
*/
async getLastAssistantText(): Promise<string | null> {
const response = await this.send({ type: "get_last_assistant_text" });
this.assertSuccess(response);
return (response.data as { text: string | null }).text;
}
/**
* List available slash commands.
*/
async getCommands(): Promise<SlashCommand[]> {
const response = await this.send({ type: "get_commands" });
this.assertSuccess(response);
return (response.data as { commands: SlashCommand[] }).commands;
}
// =========================================================================
// Fork
// =========================================================================
/**
* Get messages that can be used as fork points.
*/
async getForkMessages(): Promise<{ entryId: string; text: string }[]> {
const response = await this.send({ type: "get_fork_messages" });
this.assertSuccess(response);
return (response.data as { messages: { entryId: string; text: string }[] })
.messages;
}
/**
* Fork the session at the given entry point.
*/
async forkSession(
entryId: string,
): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "fork", entryId });
this.assertSuccess(response);
return response.data as { text: string; cancelled: boolean };
}
// =========================================================================
// Queue Modes
// =========================================================================
/**
* Set steering queue mode.
*/
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
const response = await this.send({ type: "set_steering_mode", mode });
this.assertSuccess(response);
}
/**
* Set follow-up queue mode.
*/
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
const response = await this.send({ type: "set_follow_up_mode", mode });
this.assertSuccess(response);
}
dispose(): void {
this.stop();
for (const d of this.disposables) {
d.dispose();
}
}
// -- Private helpers ------------------------------------------------------
private drainBuffer(): void {
while (true) {
const newlineIdx = this.buffer.indexOf("\n");
if (newlineIdx === -1) {
break;
}
let line = this.buffer.slice(0, newlineIdx);
this.buffer = this.buffer.slice(newlineIdx + 1);
if (line.endsWith("\r")) {
line = line.slice(0, -1);
}
if (!line) {
continue;
}
this.handleLine(line);
}
}
private handleLine(line: string): void {
let data: Record<string, unknown>;
try {
data = JSON.parse(line);
} catch {
return; // ignore non-JSON lines
}
// Response to a pending request
if (
data.type === "response" &&
typeof data.id === "string" &&
this.pendingRequests.has(data.id)
) {
const pending = this.pendingRequests.get(data.id)!;
this.pendingRequests.delete(data.id);
clearTimeout(pending.timer);
pending.resolve(data as unknown as RpcResponse);
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(`SF: ${message}`);
} else if (notifyType === "warning") {
vscode.window.showWarningMessage(`SF: ${message}`);
} else {
vscode.window.showInformationMessage(`SF: ${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("SF client not started"));
}
const id = `req_${++this.requestId}`;
const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Timeout waiting for response to ${command.type}`));
}, 30_000);
this.pendingRequests.set(id, { resolve, reject, timer });
this.process!.stdin!.write(JSON.stringify(fullCommand) + "\n");
});
}
private assertSuccess(response: RpcResponse): void {
if (!response.success) {
throw new Error(response.error ?? "Unknown RPC error");
}
}
private rejectAllPending(reason: string): void {
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
this.pendingRequests.clear();
}
}

View file

@ -1,865 +0,0 @@
import * as vscode from "vscode";
import type { SessionStats, SfClient, ThinkingLevel } from "./sf-client.js";
/**
* Send a message through VS Code's Chat panel so the user sees the response.
* Opens the Chat panel and pre-fills the @sf 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 SfSidebarProvider implements vscode.WebviewViewProvider {
public static readonly viewId = "sf-sidebar";
private view?: vscode.WebviewView;
private disposables: vscode.Disposable[] = [];
private refreshTimer: ReturnType<typeof setInterval> | undefined;
constructor(
readonly _extensionUri: vscode.Uri,
private readonly client: SfClient,
) {
this.disposables.push(
client.onConnectionChange(() => this.refresh()),
client.onEvent((evt) => {
switch (evt.type) {
case "agent_start":
case "agent_end":
case "model_switched":
case "compaction_start":
case "compaction_end":
case "retry_start":
case "retry_end":
case "retry_error":
this.refresh();
break;
}
}),
);
}
resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
): void {
this.view = webviewView;
webviewView.webview.options = {
enableScripts: true,
};
webviewView.webview.onDidReceiveMessage(
async (msg: { command: string; value?: string }) => {
switch (msg.command) {
case "start":
await vscode.commands.executeCommand("sf.start");
break;
case "stop":
await vscode.commands.executeCommand("sf.stop");
break;
case "newSession":
await vscode.commands.executeCommand("sf.newSession");
break;
case "cycleModel":
await vscode.commands.executeCommand("sf.cycleModel");
break;
case "cycleThinking":
await vscode.commands.executeCommand("sf.cycleThinking");
break;
case "switchModel":
await vscode.commands.executeCommand("sf.switchModel");
break;
case "setThinking":
await vscode.commands.executeCommand("sf.setThinking");
break;
case "compact":
await vscode.commands.executeCommand("sf.compact");
break;
case "abort":
await vscode.commands.executeCommand("sf.abort");
break;
case "exportHtml":
await vscode.commands.executeCommand("sf.exportHtml");
break;
case "sessionStats":
await vscode.commands.executeCommand("sf.sessionStats");
break;
case "listCommands":
await vscode.commands.executeCommand("sf.listCommands");
break;
case "toggleAutoCompaction":
if (this.client.isConnected) {
const state = await this.client.getState().catch(() => null);
if (state) {
await this.client
.setAutoCompaction(!state.autoCompactionEnabled)
.catch(() => {});
this.refresh();
}
}
break;
case "toggleAutoRetry":
if (this.client.isConnected) {
await this.client
.setAutoRetry(!this.client.autoRetryEnabled)
.catch(() => {});
this.refresh();
}
break;
case "setSessionName":
await vscode.commands.executeCommand("sf.setSessionName");
break;
case "copyLastResponse":
await vscode.commands.executeCommand("sf.copyLastResponse");
break;
case "autoMode":
await sendViaChat("@sf /sf autonomous");
break;
case "nextUnit":
await sendViaChat("@sf /sf 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) {
await sendViaChat(`@sf /sf quick ${quickInput}`);
}
break;
}
case "capture": {
const thought = await vscode.window.showInputBox({
prompt: "Capture a thought",
placeHolder: "e.g. we should also handle the edge case for...",
});
if (thought) {
await sendViaChat(`@sf /sf capture ${thought}`);
}
break;
}
case "status":
await sendViaChat("@sf /sf status");
break;
case "forkSession":
await vscode.commands.executeCommand("sf.forkSession");
break;
case "toggleSteeringMode":
await vscode.commands.executeCommand("sf.toggleSteeringMode");
break;
case "toggleFollowUpMode":
await vscode.commands.executeCommand("sf.toggleFollowUpMode");
break;
case "showHistory":
await vscode.commands.executeCommand("sf.showHistory");
break;
}
},
);
// Periodic refresh while connected (for token stats)
this.refreshTimer = setInterval(() => {
if (this.client.isConnected) {
this.refresh();
}
}, 10_000);
this.refresh();
}
async refresh(): Promise<void> {
if (!this.view) {
return;
}
let modelName = "N/A";
let modelShort = "";
let sessionId = "N/A";
let sessionName = "";
let messageCount = 0;
let pendingMessageCount = 0;
let thinkingLevel: ThinkingLevel = "off";
let isStreaming = false;
let isCompacting = false;
let autoCompaction = false;
let autoRetry = false;
let stats: SessionStats | null = null;
let contextWindow = 0;
let steeringMode: "all" | "one-at-a-time" = "all";
let followUpMode: "all" | "one-at-a-time" = "all";
if (this.client.isConnected) {
autoRetry = this.client.autoRetryEnabled;
try {
const state = await this.client.getState();
modelName = state.model
? `${state.model.provider}/${state.model.id}`
: "Not set";
modelShort = state.model?.id ?? "";
sessionId = state.sessionId;
sessionName = state.sessionName ?? "";
messageCount = state.messageCount;
pendingMessageCount = state.pendingMessageCount;
thinkingLevel = state.thinkingLevel as ThinkingLevel;
isStreaming = state.isStreaming;
isCompacting = state.isCompacting;
autoCompaction = state.autoCompactionEnabled;
contextWindow = state.model?.contextWindow ?? 0;
steeringMode = state.steeringMode;
followUpMode = state.followUpMode;
} catch {
// State fetch failed, show defaults
}
try {
stats = await this.client.getSessionStats();
} catch {
// Stats fetch failed
}
}
const connected = this.client.isConnected;
this.view.webview.html = this.getHtml({
connected,
modelName,
modelShort,
sessionId,
sessionName,
messageCount,
pendingMessageCount,
thinkingLevel,
isStreaming,
isCompacting,
autoCompaction,
autoRetry,
stats,
contextWindow,
steeringMode,
followUpMode,
});
}
dispose(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
for (const d of this.disposables) {
d.dispose();
}
}
private getHtml(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";
}): string {
const statusColor = info.connected ? "#4ec9b0" : "#f44747";
const statusLabel = info.isStreaming
? "Working"
: info.isCompacting
? "Compacting"
: info.connected
? "Connected"
: "Disconnected";
// Model short name for header
const modelDisplay = info.modelShort || "N/A";
// Session display — name or truncated ID
const sessionDisplay =
info.sessionName ||
(info.sessionId !== "N/A" ? info.sessionId.slice(0, 8) : "N/A");
// Cost for header
const costDisplay =
info.stats?.totalCost !== undefined && info.stats.totalCost > 0
? `$${info.stats.totalCost.toFixed(4)}`
: "";
// 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";
// 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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<style>
* { 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: 8px;
}
/* ---- 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;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: ${statusColor};
flex-shrink: 0;
}
.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: 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: 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); } }
.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;
cursor: pointer;
opacity: 0.6;
}
.streaming-abort:hover { opacity: 1; }
/* ---- Context bar (inline in header) ---- */
.context-bar {
margin-top: 8px;
}
.context-track {
width: 100%;
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;
user-select: none;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
background: var(--vscode-editor-background);
}
.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;
}
/* ---- Toggle row ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
font-size: 11px;
}
.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>
${
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>
<div class="disconnected">
<p>Agent is not running</p>
<button class="start-btn" data-command="start">Start Agent</button>
</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 });
}
});
</script>
</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 {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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 = "";
for (let i = 0; i < 32; i++) {
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
}
return nonce;
}

View file

@ -1,117 +0,0 @@
import * as vscode from "vscode";
import type { SfClient, SlashCommand } from "./sf-client.js";
/**
* CompletionItemProvider that surfaces SF slash commands when the user
* types `/` at the start of a line (or after only whitespace) in Markdown,
* plaintext, and TypeScript/JavaScript files.
*
* Commands are fetched from the running agent via get_commands RPC and
* cached so the list remains available between keystrokes.
*/
export class SfSlashCompletionProvider
implements vscode.CompletionItemProvider, vscode.Disposable
{
private cachedCommands: SlashCommand[] = [];
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: SfClient) {
// Refresh cache whenever the connection (re)establishes.
this.disposables.push(
client.onConnectionChange(async (connected) => {
if (connected) {
await this.refreshCache();
} else {
this.cachedCommands = [];
}
}),
);
}
async provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[] | undefined> {
const lineText = document.lineAt(position).text;
const linePrefix = lineText.slice(0, position.character);
// Only activate when the non-whitespace content starts with `/`.
if (!/^\s*\/\S*$/.test(linePrefix)) {
return undefined;
}
// Lazily populate the cache on first use.
if (this.cachedCommands.length === 0 && this.client.isConnected) {
await this.refreshCache();
}
if (this.cachedCommands.length === 0) {
return undefined;
}
// The text the user has typed after the `/` — used for pre-filtering.
const slashIndex = linePrefix.lastIndexOf("/");
const typedAfterSlash = linePrefix.slice(slashIndex + 1);
// Range to replace: from the `/` to the current cursor position.
const replaceRange = new vscode.Range(
new vscode.Position(position.line, slashIndex),
position,
);
return this.cachedCommands
.filter(
(cmd) =>
typedAfterSlash.length === 0 ||
cmd.name.toLowerCase().startsWith(typedAfterSlash.toLowerCase()),
)
.map((cmd) => this.toCompletionItem(cmd, replaceRange));
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private async refreshCache(): Promise<void> {
try {
const all = await this.client.getCommands();
// Only show /sf commands — filter out unrelated extension/skill commands
this.cachedCommands = all.filter((cmd) => cmd.name.startsWith("sf"));
} catch {
// Silently ignore — agent may not be ready yet.
}
}
private toCompletionItem(
cmd: SlashCommand,
replaceRange: vscode.Range,
): vscode.CompletionItem {
const item = new vscode.CompletionItem(
`/${cmd.name}`,
vscode.CompletionItemKind.Event,
);
item.insertText = `/${cmd.name}`;
item.filterText = `/${cmd.name}`;
item.sortText = cmd.name;
item.range = replaceRange;
item.commitCharacters = [" ", "\n"];
const sourceNote = `Source: \`${cmd.source}\`${cmd.location ? ` (${cmd.location})` : ""}`;
if (cmd.description) {
item.detail = cmd.description;
item.documentation = new vscode.MarkdownString(
`**/${cmd.name}** — ${cmd.description}\n\n${sourceNote}`,
);
} else {
item.documentation = new vscode.MarkdownString(
`**/${cmd.name}**\n\n${sourceNote}`,
);
}
return item;
}
}

View file

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,4 +1,3 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},