chore: remove vscode extension and tune knip
This commit is contained in:
parent
ab6da23789
commit
062e8e3c9f
45 changed files with 143 additions and 11275 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/* |
|
||||
|
|
|
|||
|
|
@ -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/*`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
124
knip.json
Normal 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
792
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ const RISK_TIERS = {
|
|||
"Migration",
|
||||
"Onboarding",
|
||||
"Memory Extension",
|
||||
"VS Code Extension",
|
||||
"Voice",
|
||||
"CMux",
|
||||
"Mac Tools",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
});
|
||||
|
|
@ -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["']/);
|
||||
|
|
|
|||
3
vscode-extension/.gitignore
vendored
3
vscode-extension/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
dist/
|
||||
node_modules/
|
||||
*.vsix
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
.vscode/**
|
||||
.vscode-test/**
|
||||
src/**
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
**/*.ts
|
||||
!dist/**
|
||||
node_modules/**
|
||||
**/*.map
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 |
4002
vscode-extension/package-lock.json
generated
4002
vscode-extension/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 2–3 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");
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function getNonce(): string {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let nonce = "";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ?? "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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">▼</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">▼</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">▼</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">▼</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue