chore(merge): resolve conflicts with upstream/main for PR #3138
- auto-worktree.ts: take upstream's MERGE_HEAD cleanup wording/order - state.ts: take upstream's inline disk→DB reconciliation (#2631) over the simpler "always call deriveStateFromDb" approach
This commit is contained in:
commit
d929e9ceed
45 changed files with 5164 additions and 415 deletions
241
.plans/ollama-native-provider.md
Normal file
241
.plans/ollama-native-provider.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Ollama Extension — First-Class Local LLM Support
|
||||
|
||||
## Status: DRAFT — Awaiting approval
|
||||
|
||||
## Problem
|
||||
|
||||
Ollama support in GSD2 currently requires manual `models.json` configuration. Users must:
|
||||
1. Know the OpenAI-compatibility endpoint (`localhost:11434/v1`)
|
||||
2. Manually list every model they want to use
|
||||
3. Set compat flags (`supportsDeveloperRole: false`, etc.)
|
||||
4. Use a dummy API key
|
||||
|
||||
There's an `ollama-cloud` provider for hosted Ollama, and a discovery adapter that can list models, but no first-class **local Ollama** extension that "just works."
|
||||
|
||||
## Goal
|
||||
|
||||
Make Ollama the easiest way to use GSD2 — zero config when Ollama is running locally. All Ollama functionality lives in a single extension: `src/resources/extensions/ollama/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything is a self-contained extension under `src/resources/extensions/ollama/`. The extension:
|
||||
- Auto-detects Ollama on startup via health check
|
||||
- Discovers and registers local models with the model registry
|
||||
- Provides native Ollama API streaming (not OpenAI shim)
|
||||
- Exposes `/ollama` slash commands for model management
|
||||
- Registers an LLM-callable tool for model pull/status
|
||||
|
||||
Minimal core changes — only `KnownProvider` and `KnownApi` type additions in `pi-ai`, and `env-api-keys.ts` for key resolution. Everything else is in the extension.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/resources/extensions/ollama/
|
||||
├── index.ts # Extension entry — wires everything on session_start
|
||||
├── ollama-client.ts # HTTP client for Ollama REST API (/api/*)
|
||||
├── ollama-discovery.ts # Model discovery + capability detection
|
||||
├── ollama-provider.ts # Native /api/chat streaming provider (registers with pi-ai)
|
||||
├── ollama-commands.ts # /ollama slash commands (status, pull, list, remove, ps)
|
||||
├── ollama-tool.ts # LLM-callable tool for model management
|
||||
├── model-capabilities.ts # Known model capability table (context window, vision, reasoning)
|
||||
└── types.ts # Shared types for Ollama API responses
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
### Phase 1: Auto-Discovery + OpenAI-Compat Routing
|
||||
|
||||
**What:** Extension that auto-detects Ollama, discovers models, registers them using the existing `openai-completions` API provider. Zero config needed.
|
||||
|
||||
**Extension files:**
|
||||
- `ollama/index.ts` — Main entry. On `session_start`:
|
||||
1. Probe `localhost:11434` (or `OLLAMA_HOST`) with 1.5s timeout
|
||||
2. If reachable, discover models via `/api/tags`
|
||||
3. Register discovered models with `ctx.modelRegistry` using correct defaults
|
||||
4. Show status widget if Ollama is detected
|
||||
- `ollama/ollama-client.ts` — Low-level HTTP client:
|
||||
- `isRunning()` — `GET /` health check
|
||||
- `getVersion()` — `GET /api/version`
|
||||
- `listModels()` — `GET /api/tags`
|
||||
- `showModel(name)` — `POST /api/show` (details, template, parameters, size)
|
||||
- `getRunningModels()` — `GET /api/ps` (loaded models, VRAM usage)
|
||||
- `pullModel(name, onProgress)` — `POST /api/pull` (streaming progress)
|
||||
- `deleteModel(name)` — `DELETE /api/delete`
|
||||
- `copyModel(source, dest)` — `POST /api/copy`
|
||||
- Respects `OLLAMA_HOST` env var for non-default endpoints
|
||||
- `ollama/ollama-discovery.ts` — Enhanced model discovery:
|
||||
- Calls `/api/tags` to get model list
|
||||
- Calls `/api/show` per model (batch, cached) to get:
|
||||
- `details.parameter_size` → estimate context window
|
||||
- `details.families` → detect vision (clip), reasoning (deepseek-r1)
|
||||
- `modelfile` → extract default parameters
|
||||
- Returns enriched `DiscoveredModel[]` with proper capabilities
|
||||
- `ollama/model-capabilities.ts` — Known model lookup table:
|
||||
- Maps well-known model families to capabilities
|
||||
- e.g., `llama3.1` → `{ contextWindow: 131072, input: ["text"] }`
|
||||
- e.g., `llava` → `{ contextWindow: 4096, input: ["text", "image"] }`
|
||||
- e.g., `deepseek-r1` → `{ reasoning: true, contextWindow: 131072 }`
|
||||
- e.g., `qwen2.5-coder` → `{ contextWindow: 131072, input: ["text"] }`
|
||||
- Fallback: estimate from parameter count if not in table
|
||||
- `ollama/types.ts` — Ollama API response types
|
||||
|
||||
**Core changes (minimal):**
|
||||
- `packages/pi-ai/src/types.ts` — Add `"ollama"` to `KnownProvider`
|
||||
- `packages/pi-ai/src/env-api-keys.ts` — Add `"ollama"` key resolution (returns `"ollama"` placeholder — no real key needed)
|
||||
- `src/onboarding.ts` — Add `"ollama"` to provider selection list
|
||||
- `src/wizard.ts` — Add `ollama` entry (no key required)
|
||||
|
||||
**Model registration details:**
|
||||
Each discovered model registers as:
|
||||
```typescript
|
||||
{
|
||||
id: "llama3.1:8b", // from /api/tags
|
||||
name: "Llama 3.1 8B", // humanized
|
||||
api: "openai-completions", // uses existing provider
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false, // from capabilities table
|
||||
input: ["text"], // from capabilities table
|
||||
contextWindow: 131072, // from capabilities table or /api/show
|
||||
maxTokens: 16384, // conservative default
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsUsageInStreaming: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- `gsd --list-models` shows all locally-pulled Ollama models automatically
|
||||
- `/model ollama/llama3.1:8b` works without any config file
|
||||
- If Ollama isn't running, extension is silent — no errors, no models listed
|
||||
- `models.json` overrides still work (user config wins over auto-discovery)
|
||||
|
||||
### Phase 2: Native Ollama API Provider (`/api/chat`)
|
||||
|
||||
**What:** A dedicated streaming provider that talks Ollama's native protocol instead of the OpenAI compatibility shim.
|
||||
|
||||
**Extension files:**
|
||||
- `ollama/ollama-provider.ts` — Native `/api/chat` streaming:
|
||||
- Registers `"ollama-chat"` API with `registerApiProvider()`
|
||||
- Implements `stream()` and `streamSimple()`:
|
||||
- Maps GSD `Context` → Ollama messages format
|
||||
- Maps GSD `Tool[]` → Ollama tool format
|
||||
- Streams NDJSON responses, maps back to `AssistantMessage` events
|
||||
- Extracts `<think>` blocks for reasoning models (deepseek-r1, qwq)
|
||||
- Ollama-specific options:
|
||||
- `keep_alive` — control model memory retention (default: "5m")
|
||||
- `num_ctx` — pass through model's context window
|
||||
- `num_predict` — max output tokens
|
||||
- Temperature, top_p, top_k
|
||||
- Response metadata:
|
||||
- `eval_count` / `eval_duration` → tokens/sec in usage stats
|
||||
- `total_duration`, `load_duration` → performance visibility
|
||||
- Vision support: converts image content to base64 for multimodal models
|
||||
|
||||
**Core changes:**
|
||||
- `packages/pi-ai/src/types.ts` — Add `"ollama-chat"` to `KnownApi`
|
||||
|
||||
**Phase 1 models switch to `api: "ollama-chat"` by default.** Users can force OpenAI-compat via `models.json` override if needed.
|
||||
|
||||
**Why native over OpenAI-compat:**
|
||||
- Full `keep_alive` / `num_ctx` control
|
||||
- Better error messages (Ollama-native vs generic OpenAI)
|
||||
- More reliable tool calling on Ollama's native format
|
||||
- Performance metrics in response (tokens/sec)
|
||||
- Foundation for model management commands
|
||||
|
||||
### Phase 3: Local LLM Management UX
|
||||
|
||||
**What:** `/ollama` slash commands and an LLM tool for model management.
|
||||
|
||||
**Extension files:**
|
||||
- `ollama/ollama-commands.ts` — Slash commands registered via `pi.registerCommand()`:
|
||||
- `/ollama` — Status overview:
|
||||
```
|
||||
Ollama v0.5.7 — running (localhost:11434)
|
||||
|
||||
Loaded:
|
||||
llama3.1:8b 4.7 GB VRAM idle 3m
|
||||
|
||||
Available:
|
||||
llama3.1:8b (4.7 GB)
|
||||
qwen2.5-coder:7b (4.4 GB)
|
||||
deepseek-r1:8b (4.9 GB)
|
||||
```
|
||||
- `/ollama pull <model>` — Pull with streaming progress via `ctx.ui.setWidget()`
|
||||
- `/ollama list` — List all local models with sizes and families
|
||||
- `/ollama remove <model>` — Delete a model (with confirmation)
|
||||
- `/ollama ps` — Running models + VRAM usage
|
||||
- `ollama/ollama-tool.ts` — LLM-callable tool registered via `pi.registerTool()`:
|
||||
- `ollama_manage` tool — lets the agent pull/list/check models
|
||||
- Parameters: `{ action: "list" | "pull" | "status" | "ps", model?: string }`
|
||||
- Use case: agent detects it needs a model, pulls it automatically
|
||||
|
||||
**UX Flow:**
|
||||
```
|
||||
$ gsd
|
||||
> /ollama
|
||||
Ollama v0.5.7 — running (localhost:11434)
|
||||
Loaded:
|
||||
llama3.1:8b — 4.7 GB VRAM, idle 3m
|
||||
Available:
|
||||
llama3.1:8b (4.7 GB)
|
||||
qwen2.5-coder:7b (4.4 GB)
|
||||
deepseek-r1:8b (4.9 GB)
|
||||
|
||||
> /ollama pull codestral:22b
|
||||
Pulling codestral:22b...
|
||||
████████████████████████████░░░░ 78% (14.2 GB / 18.1 GB)
|
||||
✓ codestral:22b ready
|
||||
|
||||
> /model ollama/codestral:22b
|
||||
Switched to codestral:22b (local, Ollama)
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** — Auto-discovery with OpenAI-compat routing. Biggest user impact, smallest risk.
|
||||
2. **Phase 3** — Management UX (`/ollama` commands). Valuable even before native API.
|
||||
3. **Phase 2** — Native `/api/chat` provider. Optimization over OpenAI-compat; do last.
|
||||
|
||||
## Core Changes Summary (minimal)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/pi-ai/src/types.ts` | Add `"ollama"` to `KnownProvider`, `"ollama-chat"` to `KnownApi` (Phase 2) |
|
||||
| `packages/pi-ai/src/env-api-keys.ts` | Add `"ollama"` → always returns `"ollama"` placeholder |
|
||||
| `src/onboarding.ts` | Add `"ollama"` to provider picker |
|
||||
| `src/wizard.ts` | Add `"ollama"` key mapping (no key required) |
|
||||
|
||||
Everything else lives in `src/resources/extensions/ollama/`.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Ollama not running — startup probe latency | 1.5s timeout; cache result; probe async so it doesn't block TUI paint |
|
||||
| Model capabilities unknown | Known-model table + `/api/show` fallback + parameter_size estimation |
|
||||
| Tool calling unreliable on small models | Detect param count; warn on <7B models |
|
||||
| Ollama API changes between versions | Version detect via `/api/version`; stable endpoints only |
|
||||
| Conflicts with `models.json` Ollama config | User config always wins; auto-discovered models merge beneath manual config |
|
||||
| Extension disabled — no impact on core | Extension is additive; disabling removes all Ollama features cleanly |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests: `ollama-client.ts` with mocked fetch responses
|
||||
- Unit tests: `ollama-discovery.ts` model capability parsing
|
||||
- Unit tests: `ollama-provider.ts` message format mapping + NDJSON stream parsing
|
||||
- Unit tests: `model-capabilities.ts` known model lookups
|
||||
- Integration test: mock HTTP server simulating Ollama `/api/tags`, `/api/chat`, `/api/pull`
|
||||
- Manual test: real Ollama instance with llama3.1, qwen2.5-coder, deepseek-r1
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Startup probe** — Probe Ollama on `session_start` (adds ~1.5s if not running) or lazy on first `/model`? **Recommendation: async probe on session_start (non-blocking), eager if `OLLAMA_HOST` is set.**
|
||||
2. **Auto-start** — Try to launch Ollama if installed but not running? **Recommendation: no — too invasive. Show helpful message in `/ollama` status.**
|
||||
3. **Vision support** — Support multimodal models (llava, etc.) in Phase 2 native API? **Recommendation: yes, detected via capabilities table.**
|
||||
4. **Model refresh** — How often to re-probe Ollama for new models? **Recommendation: on `/ollama list`, on `/model` command, and every 5 min (existing TTL).**
|
||||
|
|
@ -137,6 +137,7 @@ export function getEnvApiKey(provider: any): string | undefined {
|
|||
"opencode-go": "OPENCODE_API_KEY",
|
||||
"kimi-coding": "KIMI_API_KEY",
|
||||
"alibaba-coding-plan": "ALIBABA_API_KEY",
|
||||
ollama: "OLLAMA_API_KEY",
|
||||
"ollama-cloud": "OLLAMA_API_KEY",
|
||||
"custom-openai": "CUSTOM_OPENAI_API_KEY",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type KnownProvider =
|
|||
| "opencode-go"
|
||||
| "kimi-coding"
|
||||
| "alibaba-coding-plan"
|
||||
| "ollama"
|
||||
| "ollama-cloud";
|
||||
export type Provider = KnownProvider | string;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|||
"opencode-go": "kimi-k2.5",
|
||||
"kimi-coding": "kimi-k2-thinking",
|
||||
"alibaba-coding-plan": "qwen3.5-plus",
|
||||
ollama: "llama3.1:8b",
|
||||
"ollama-cloud": "qwen3:32b",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ const LLM_PROVIDER_IDS = [
|
|||
'xai',
|
||||
'openrouter',
|
||||
'mistral',
|
||||
'ollama',
|
||||
'ollama-cloud',
|
||||
'custom-openai',
|
||||
]
|
||||
|
|
@ -90,6 +91,7 @@ const OTHER_PROVIDERS = [
|
|||
{ value: 'xai', label: 'xAI (Grok)' },
|
||||
{ value: 'openrouter', label: 'OpenRouter' },
|
||||
{ value: 'mistral', label: 'Mistral' },
|
||||
{ value: 'ollama', label: 'Ollama (Local)' },
|
||||
{ value: 'ollama-cloud', label: 'Ollama Cloud' },
|
||||
{ value: 'custom-openai', label: 'Custom (OpenAI-compatible)' },
|
||||
]
|
||||
|
|
@ -335,6 +337,9 @@ async function runLlmStep(p: ClackModule, pc: PicoModule, authStorage: AuthStora
|
|||
if (provider === 'custom-openai') {
|
||||
return await runCustomOpenAIFlow(p, pc, authStorage)
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
return await runOllamaLocalFlow(p, pc, authStorage)
|
||||
}
|
||||
const label = provider === 'anthropic' ? 'Anthropic'
|
||||
: provider === 'openai' ? 'OpenAI'
|
||||
: OTHER_PROVIDERS.find(op => op.value === provider)?.label ?? String(provider)
|
||||
|
|
@ -444,6 +449,54 @@ async function runApiKeyFlow(
|
|||
return true
|
||||
}
|
||||
|
||||
// ─── Ollama Local Flow ───────────────────────────────────────────────────────
|
||||
|
||||
async function runOllamaLocalFlow(
|
||||
p: ClackModule,
|
||||
pc: PicoModule,
|
||||
authStorage: AuthStorage,
|
||||
): Promise<boolean> {
|
||||
const host = process.env.OLLAMA_HOST || 'http://localhost:11434'
|
||||
|
||||
const s = p.spinner()
|
||||
s.start(`Checking Ollama at ${host}...`)
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 3000)
|
||||
const response = await fetch(host, { signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (response.ok) {
|
||||
s.stop(`Ollama is running at ${pc.green(host)}`)
|
||||
// Store a placeholder so the provider is recognized as authenticated
|
||||
authStorage.set('ollama', { type: 'api_key', key: 'ollama' })
|
||||
p.log.success(`${pc.green('Ollama (Local)')} configured — no API key needed`)
|
||||
p.log.info(pc.dim('Models are discovered automatically from your local Ollama instance.'))
|
||||
return true
|
||||
} else {
|
||||
s.stop('Ollama check failed')
|
||||
p.log.warn(`Ollama responded with status ${response.status} at ${host}`)
|
||||
}
|
||||
} catch {
|
||||
s.stop('Ollama not detected')
|
||||
p.log.warn(`Could not reach Ollama at ${host}`)
|
||||
p.log.info(pc.dim('Install Ollama from https://ollama.com and run "ollama serve"'))
|
||||
p.log.info(pc.dim('Set OLLAMA_HOST if using a non-default address.'))
|
||||
}
|
||||
|
||||
// Even if not reachable now, save the config — the extension will detect it at runtime
|
||||
const proceed = await p.confirm({
|
||||
message: 'Save Ollama as your provider anyway? (it will auto-detect when running)',
|
||||
})
|
||||
|
||||
if (p.isCancel(proceed) || !proceed) return false
|
||||
|
||||
authStorage.set('ollama', { type: 'api_key', key: 'ollama' })
|
||||
p.log.success(`${pc.green('Ollama (Local)')} saved — models will appear when Ollama is running`)
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Custom OpenAI-compatible Flow ────────────────────────────────────────────
|
||||
|
||||
async function runCustomOpenAIFlow(
|
||||
|
|
|
|||
|
|
@ -1566,17 +1566,18 @@ export function mergeMilestoneToMain(
|
|||
// Non-fatal — proceed with merge; untracked files may block it
|
||||
}
|
||||
|
||||
// 7b. Clean up stale merge state files before starting the squash merge (#2912).
|
||||
// A previous failed merge, libgit2 native path, or external tooling may leave
|
||||
// MERGE_HEAD on disk. git refuses to start a new merge when MERGE_HEAD exists,
|
||||
// causing `git merge --squash` to fail with "You have not concluded your merge".
|
||||
// 7b. Clean up stale merge state before attempting squash merge (#2912).
|
||||
// A leftover MERGE_HEAD (from a previous failed merge, libgit2 native path,
|
||||
// or interrupted operation) causes `git merge --squash` to refuse with
|
||||
// "fatal: You have not concluded your merge (MERGE_HEAD exists)".
|
||||
// Defensively remove merge artifacts before starting.
|
||||
try {
|
||||
const gitDir_ = resolveGitDir(originalBasePath_);
|
||||
for (const f of ["MERGE_HEAD", "MERGE_MSG", "SQUASH_MSG"]) {
|
||||
for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
|
||||
const p = join(gitDir_, f);
|
||||
if (existsSync(p)) unlinkSync(p);
|
||||
}
|
||||
} catch { /* best-effort — proceed and let the merge report the error if it fails */ }
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
// 8. Squash merge — auto-resolve .gsd/ state file conflicts (#530)
|
||||
const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch);
|
||||
|
|
|
|||
|
|
@ -95,6 +95,27 @@ export async function buildBeforeAgentStartResult(
|
|||
}
|
||||
}
|
||||
|
||||
let codebaseBlock = "";
|
||||
const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE");
|
||||
if (existsSync(codebasePath)) {
|
||||
try {
|
||||
const rawContent = readFileSync(codebasePath, "utf-8").trim();
|
||||
if (rawContent) {
|
||||
// Cap injection size to ~2 000 tokens to avoid bloating every request.
|
||||
// Full map is always available at .gsd/CODEBASE.md.
|
||||
const MAX_CODEBASE_CHARS = 8_000;
|
||||
const generatedMatch = rawContent.match(/Generated: (\S+)/);
|
||||
const generatedAt = generatedMatch?.[1] ?? "unknown";
|
||||
const content = rawContent.length > MAX_CODEBASE_CHARS
|
||||
? rawContent.slice(0, MAX_CODEBASE_CHARS) + "\n\n*(truncated — see .gsd/CODEBASE.md for full map)*"
|
||||
: rawContent;
|
||||
codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, may be stale — run /gsd codebase update to refresh)]\n\n${content}`;
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
warnDeprecatedAgentInstructions();
|
||||
|
||||
const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd());
|
||||
|
|
@ -103,7 +124,7 @@ export async function buildBeforeAgentStartResult(
|
|||
const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd()) : null;
|
||||
|
||||
const worktreeBlock = buildWorktreeContextBlock();
|
||||
const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
|
||||
const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
|
||||
|
||||
stopContextTimer({
|
||||
systemPromptSize: fullSystem.length,
|
||||
|
|
|
|||
351
src/resources/extensions/gsd/codebase-generator.ts
Normal file
351
src/resources/extensions/gsd/codebase-generator.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* GSD Codebase Map Generator
|
||||
*
|
||||
* Produces .gsd/CODEBASE.md — a structural table of contents for the project.
|
||||
* Gives fresh agent contexts instant orientation without filesystem exploration.
|
||||
*
|
||||
* Generation: walk `git ls-files`, group by directory, output with descriptions.
|
||||
* Maintenance: agent updates descriptions as it works; incremental update preserves them.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, extname } from "node:path";
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CodebaseMapOptions {
|
||||
excludePatterns?: string[];
|
||||
maxFiles?: number;
|
||||
collapseThreshold?: number;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DirectoryGroup {
|
||||
path: string;
|
||||
files: FileEntry[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_EXCLUDES = [
|
||||
".gsd/",
|
||||
".planning/",
|
||||
".git/",
|
||||
"node_modules/",
|
||||
"dist/",
|
||||
"build/",
|
||||
".next/",
|
||||
"coverage/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"vendor/",
|
||||
];
|
||||
|
||||
const DEFAULT_MAX_FILES = 500;
|
||||
const DEFAULT_COLLAPSE_THRESHOLD = 20;
|
||||
|
||||
// ─── Parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse an existing CODEBASE.md to extract file → description mappings.
|
||||
* Also scans <!-- gsd:collapsed-descriptions --> comment blocks to preserve
|
||||
* descriptions for files in collapsed directories across incremental updates.
|
||||
*/
|
||||
export function parseCodebaseMap(content: string): Map<string, string> {
|
||||
const descriptions = new Map<string, string>();
|
||||
let inCollapsedBlock = false;
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
// Track collapsed-description comment blocks
|
||||
if (line.trimStart().startsWith("<!-- gsd:collapsed-descriptions")) {
|
||||
inCollapsedBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (inCollapsedBlock && line.trimStart().startsWith("-->")) {
|
||||
inCollapsedBlock = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match: - `path/to/file.ts` — Description here
|
||||
const match = line.match(/^- `(.+?)` — (.+)$/);
|
||||
if (match) {
|
||||
descriptions.set(match[1], match[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match: - `path/to/file.ts` (no description) — only outside collapsed blocks
|
||||
if (!inCollapsedBlock) {
|
||||
const bareMatch = line.match(/^- `(.+?)`\s*$/);
|
||||
if (bareMatch) {
|
||||
descriptions.set(bareMatch[1], "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
// ─── File Enumeration ────────────────────────────────────────────────────────
|
||||
|
||||
function shouldExclude(filePath: string, excludes: string[]): boolean {
|
||||
for (const pattern of excludes) {
|
||||
if (pattern.endsWith("/")) {
|
||||
if (filePath.startsWith(pattern) || filePath.includes(`/${pattern}`)) return true;
|
||||
} else if (filePath === pattern || filePath.endsWith(`/${pattern}`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Skip binary/lock files
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if ([".lock", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".svg"].includes(ext)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function lsFiles(basePath: string): string[] {
|
||||
try {
|
||||
const result = execSync("git ls-files", { cwd: basePath, encoding: "utf-8", timeout: 10000 });
|
||||
return result.split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate tracked files, applying exclusions and the maxFiles cap.
|
||||
* Returns both the file list and whether truncation occurred.
|
||||
*/
|
||||
function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): { files: string[]; truncated: boolean } {
|
||||
const allFiles = lsFiles(basePath);
|
||||
const filtered = allFiles.filter((f) => !shouldExclude(f, excludes));
|
||||
const truncated = filtered.length > maxFiles;
|
||||
return { files: truncated ? filtered.slice(0, maxFiles) : filtered, truncated };
|
||||
}
|
||||
|
||||
// ─── Grouping ────────────────────────────────────────────────────────────────
|
||||
|
||||
function groupByDirectory(
|
||||
files: string[],
|
||||
descriptions: Map<string, string>,
|
||||
collapseThreshold: number,
|
||||
): DirectoryGroup[] {
|
||||
const dirMap = new Map<string, FileEntry[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const dir = dirname(file);
|
||||
const dirKey = dir === "." ? "" : dir;
|
||||
if (!dirMap.has(dirKey)) {
|
||||
dirMap.set(dirKey, []);
|
||||
}
|
||||
dirMap.get(dirKey)!.push({
|
||||
path: file,
|
||||
description: descriptions.get(file) ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const groups: DirectoryGroup[] = [];
|
||||
const sortedDirs = [...dirMap.keys()].sort();
|
||||
|
||||
for (const dir of sortedDirs) {
|
||||
const dirFiles = dirMap.get(dir)!;
|
||||
dirFiles.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
groups.push({
|
||||
path: dir,
|
||||
files: dirFiles,
|
||||
collapsed: dirFiles.length > collapseThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ─── Rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string {
|
||||
const lines: string[] = [];
|
||||
const now = new Date().toISOString().split(".")[0] + "Z";
|
||||
const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0);
|
||||
|
||||
lines.push("# Codebase Map");
|
||||
lines.push("");
|
||||
lines.push(`Generated: ${now} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`);
|
||||
if (truncated) {
|
||||
lines.push(`Note: Truncated to first ${totalFiles} files. Run with higher --max-files to include all.`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
for (const group of groups) {
|
||||
const heading = group.path || "(root)";
|
||||
lines.push(`### ${heading}/`);
|
||||
|
||||
if (group.collapsed) {
|
||||
// Summarize collapsed directories
|
||||
const extensions = new Map<string, number>();
|
||||
for (const f of group.files) {
|
||||
const ext = extname(f.path) || "(no ext)";
|
||||
extensions.set(ext, (extensions.get(ext) ?? 0) + 1);
|
||||
}
|
||||
const extSummary = [...extensions.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([ext, count]) => `${count} ${ext}`)
|
||||
.join(", ");
|
||||
lines.push(`- *(${group.files.length} files: ${extSummary})*`);
|
||||
|
||||
// Preserve any existing descriptions in a hidden comment block so
|
||||
// incremental updates can recover them via parseCodebaseMap.
|
||||
const descLines = group.files
|
||||
.filter((f) => f.description)
|
||||
.map((f) => `- \`${f.path}\` — ${f.description}`);
|
||||
if (descLines.length > 0) {
|
||||
lines.push("<!-- gsd:collapsed-descriptions");
|
||||
lines.push(...descLines);
|
||||
lines.push("-->");
|
||||
}
|
||||
} else {
|
||||
for (const file of group.files) {
|
||||
if (file.description) {
|
||||
lines.push(`- \`${file.path}\` — ${file.description}`);
|
||||
} else {
|
||||
lines.push(`- \`${file.path}\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a fresh CODEBASE.md from scratch.
|
||||
* Preserves existing descriptions if `existingDescriptions` is provided.
|
||||
*/
|
||||
export function generateCodebaseMap(
|
||||
basePath: string,
|
||||
options?: CodebaseMapOptions,
|
||||
existingDescriptions?: Map<string, string>,
|
||||
): { content: string; fileCount: number; truncated: boolean; files: string[] } {
|
||||
const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])];
|
||||
const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD;
|
||||
|
||||
const { files, truncated } = enumerateFiles(basePath, excludes, maxFiles);
|
||||
const descriptions = existingDescriptions ?? new Map<string, string>();
|
||||
const groups = groupByDirectory(files, descriptions, collapseThreshold);
|
||||
const content = renderCodebaseMap(groups, files.length, truncated);
|
||||
|
||||
return { content, fileCount: files.length, truncated, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental update: re-scan files, preserve existing descriptions,
|
||||
* add new files, remove deleted files.
|
||||
*/
|
||||
export function updateCodebaseMap(
|
||||
basePath: string,
|
||||
options?: CodebaseMapOptions,
|
||||
): { content: string; added: number; removed: number; unchanged: number; fileCount: number; truncated: boolean } {
|
||||
const codebasePath = join(gsdRoot(basePath), "CODEBASE.md");
|
||||
|
||||
// Load existing descriptions
|
||||
let existingDescriptions = new Map<string, string>();
|
||||
if (existsSync(codebasePath)) {
|
||||
const existing = readFileSync(codebasePath, "utf-8");
|
||||
existingDescriptions = parseCodebaseMap(existing);
|
||||
}
|
||||
|
||||
const existingFiles = new Set(existingDescriptions.keys());
|
||||
|
||||
// Generate new map preserving descriptions — reuse the returned file list
|
||||
// to avoid a second enumeration (prevents race between content and stats).
|
||||
const result = generateCodebaseMap(basePath, options, existingDescriptions);
|
||||
const currentSet = new Set(result.files);
|
||||
|
||||
// Count changes
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const f of result.files) {
|
||||
if (!existingFiles.has(f)) added++;
|
||||
}
|
||||
for (const f of existingFiles) {
|
||||
if (!currentSet.has(f)) removed++;
|
||||
}
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
added,
|
||||
removed,
|
||||
unchanged: result.files.length - added,
|
||||
fileCount: result.fileCount,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write CODEBASE.md to .gsd/ directory.
|
||||
*/
|
||||
export function writeCodebaseMap(basePath: string, content: string): string {
|
||||
const root = gsdRoot(basePath);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const outPath = join(root, "CODEBASE.md");
|
||||
writeFileSync(outPath, content, "utf-8");
|
||||
return outPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read existing CODEBASE.md, or return null if it doesn't exist.
|
||||
*/
|
||||
export function readCodebaseMap(basePath: string): string | null {
|
||||
const codebasePath = join(gsdRoot(basePath), "CODEBASE.md");
|
||||
if (!existsSync(codebasePath)) return null;
|
||||
try {
|
||||
return readFileSync(codebasePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about the codebase map.
|
||||
*/
|
||||
export function getCodebaseMapStats(basePath: string): {
|
||||
exists: boolean;
|
||||
fileCount: number;
|
||||
describedCount: number;
|
||||
undescribedCount: number;
|
||||
generatedAt: string | null;
|
||||
} {
|
||||
const content = readCodebaseMap(basePath);
|
||||
if (!content) {
|
||||
return { exists: false, fileCount: 0, describedCount: 0, undescribedCount: 0, generatedAt: null };
|
||||
}
|
||||
|
||||
// Parse total file count from the header line (accurate even for collapsed dirs)
|
||||
const fileCountMatch = content.match(/Files:\s*(\d+)/);
|
||||
const totalFiles = fileCountMatch ? parseInt(fileCountMatch[1], 10) : 0;
|
||||
|
||||
// Use parseCodebaseMap to count described files (includes collapsed-description blocks)
|
||||
const descriptions = parseCodebaseMap(content);
|
||||
const described = [...descriptions.values()].filter((d) => d.length > 0).length;
|
||||
const dateMatch = content.match(/Generated: (\S+)/);
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
fileCount: totalFiles,
|
||||
describedCount: described,
|
||||
undescribedCount: totalFiles - described,
|
||||
generatedAt: dateMatch?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
164
src/resources/extensions/gsd/commands-codebase.ts
Normal file
164
src/resources/extensions/gsd/commands-codebase.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* GSD Command — /gsd codebase
|
||||
*
|
||||
* Generate and manage the codebase map (.gsd/CODEBASE.md).
|
||||
* Subcommands: generate, update, stats, help
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
||||
|
||||
import {
|
||||
generateCodebaseMap,
|
||||
updateCodebaseMap,
|
||||
writeCodebaseMap,
|
||||
getCodebaseMapStats,
|
||||
readCodebaseMap,
|
||||
} from "./codebase-generator.js";
|
||||
|
||||
const USAGE =
|
||||
"Usage: /gsd codebase [generate|update|stats]\n\n" +
|
||||
" generate [--max-files N] — Generate or regenerate CODEBASE.md\n" +
|
||||
" update — Incremental update (preserves descriptions)\n" +
|
||||
" stats — Show file count, coverage, and generation time\n" +
|
||||
" help — Show this help\n\n" +
|
||||
"With no subcommand, shows stats if a map exists or help if not.";
|
||||
|
||||
export async function handleCodebase(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
_pi: ExtensionAPI,
|
||||
): Promise<void> {
|
||||
const basePath = process.cwd();
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const sub = parts[0] ?? "";
|
||||
|
||||
switch (sub) {
|
||||
case "generate": {
|
||||
const maxFiles = parseMaxFiles(args, ctx);
|
||||
if (maxFiles === false) return; // validation failed, message already shown
|
||||
|
||||
const existing = readCodebaseMap(basePath);
|
||||
const existingDescriptions = existing
|
||||
? (await import("./codebase-generator.js")).parseCodebaseMap(existing)
|
||||
: undefined;
|
||||
|
||||
const result = generateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }, existingDescriptions);
|
||||
|
||||
if (result.fileCount === 0) {
|
||||
ctx.ui.notify(
|
||||
"Codebase map generated with 0 files.\n" +
|
||||
"Is this a git repository? Run 'git ls-files' to verify.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const outPath = writeCodebaseMap(basePath, result.content);
|
||||
ctx.ui.notify(
|
||||
`Codebase map generated: ${result.fileCount} files\n` +
|
||||
`Written to: ${outPath}` +
|
||||
(result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""),
|
||||
"success",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const existing = readCodebaseMap(basePath);
|
||||
if (!existing) {
|
||||
ctx.ui.notify(
|
||||
"No codebase map found. Run /gsd codebase generate to create one.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxFiles = parseMaxFiles(args, ctx);
|
||||
if (maxFiles === false) return;
|
||||
|
||||
const result = updateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined });
|
||||
writeCodebaseMap(basePath, result.content);
|
||||
|
||||
ctx.ui.notify(
|
||||
`Codebase map updated: ${result.fileCount} files\n` +
|
||||
` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` +
|
||||
(result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""),
|
||||
"success",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
case "stats": {
|
||||
showStats(basePath, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case "help":
|
||||
ctx.ui.notify(USAGE, "info");
|
||||
return;
|
||||
|
||||
case "": {
|
||||
// Safe default: show stats if map exists, help if not
|
||||
const existing = readCodebaseMap(basePath);
|
||||
if (existing) {
|
||||
showStats(basePath, ctx);
|
||||
} else {
|
||||
ctx.ui.notify(USAGE, "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
ctx.ui.notify(
|
||||
`Unknown subcommand "${sub}".\n\n${USAGE}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function showStats(basePath: string, ctx: ExtensionCommandContext): void {
|
||||
const stats = getCodebaseMapStats(basePath);
|
||||
if (!stats.exists) {
|
||||
ctx.ui.notify("No codebase map found. Run /gsd codebase generate to create one.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const coverage = stats.fileCount > 0
|
||||
? Math.round((stats.describedCount / stats.fileCount) * 100)
|
||||
: 0;
|
||||
|
||||
ctx.ui.notify(
|
||||
`Codebase Map Stats:\n` +
|
||||
` Files: ${stats.fileCount}\n` +
|
||||
` Described: ${stats.describedCount} (${coverage}%)\n` +
|
||||
` Undescribed: ${stats.undescribedCount}\n` +
|
||||
` Generated: ${stats.generatedAt ?? "unknown"}\n\n` +
|
||||
(stats.undescribedCount > 0
|
||||
? `Tip: Run /gsd codebase update to refresh after file changes.`
|
||||
: `Coverage is complete.`),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate --max-files flag.
|
||||
* Returns the parsed number, undefined if flag not present, or false if invalid.
|
||||
*/
|
||||
function parseMaxFiles(args: string, ctx: ExtensionCommandContext): number | undefined | false {
|
||||
const maxFilesStr = extractFlag(args, "--max-files");
|
||||
if (!maxFilesStr) return undefined;
|
||||
|
||||
const maxFiles = parseInt(maxFilesStr, 10);
|
||||
if (isNaN(maxFiles) || maxFiles < 1) {
|
||||
ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning");
|
||||
return false;
|
||||
}
|
||||
return maxFiles;
|
||||
}
|
||||
|
||||
function extractFlag(args: string, flag: string): string | undefined {
|
||||
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`);
|
||||
const match = args.match(regex);
|
||||
return match?.[1];
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
|
|||
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
|
||||
|
||||
export const GSD_COMMAND_DESCRIPTION =
|
||||
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink";
|
||||
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase";
|
||||
|
||||
export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
|
||||
{ cmd: "help", desc: "Categorized command reference with descriptions" },
|
||||
|
|
@ -71,6 +71,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
|
|||
{ cmd: "mcp", desc: "MCP server status and connectivity check (status, check <server>)" },
|
||||
{ cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" },
|
||||
{ cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" },
|
||||
{ cmd: "codebase", desc: "Generate and manage codebase map (.gsd/CODEBASE.md)" },
|
||||
];
|
||||
|
||||
const NESTED_COMPLETIONS: CompletionMap = {
|
||||
|
|
@ -225,6 +226,14 @@ const NESTED_COMPLETIONS: CompletionMap = {
|
|||
{ cmd: "pause", desc: "Pause custom workflow auto-mode" },
|
||||
{ cmd: "resume", desc: "Resume paused custom workflow auto-mode" },
|
||||
],
|
||||
codebase: [
|
||||
{ cmd: "generate", desc: "Generate or regenerate CODEBASE.md" },
|
||||
{ cmd: "generate --max-files", desc: "Generate with custom file limit (default: 500)" },
|
||||
{ cmd: "update", desc: "Incremental update (preserves descriptions)" },
|
||||
{ cmd: "update --max-files", desc: "Update with custom file limit" },
|
||||
{ cmd: "stats", desc: "Show file count, description coverage, and generation time" },
|
||||
{ cmd: "help", desc: "Show usage and available subcommands" },
|
||||
],
|
||||
};
|
||||
|
||||
function filterOptions(
|
||||
|
|
|
|||
|
|
@ -206,5 +206,10 @@ Examples:
|
|||
await handleRethink(trimmed, ctx, pi);
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "codebase" || trimmed.startsWith("codebase ")) {
|
||||
const { handleCodebase } = await import("../../commands-codebase.js");
|
||||
await handleCodebase(trimmed.replace(/^codebase\s*/, "").trim(), ctx, pi);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,15 +35,17 @@ const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = {
|
|||
"complete-slice": "light",
|
||||
"run-uat": "light",
|
||||
|
||||
// Tier 2 — Standard: research, routine planning, discussion
|
||||
// Tier 2 — Standard: research, routine discussion
|
||||
"discuss-milestone": "standard",
|
||||
"discuss-slice": "standard",
|
||||
"research-milestone": "standard",
|
||||
"research-slice": "standard",
|
||||
"plan-milestone": "standard",
|
||||
"plan-slice": "standard",
|
||||
|
||||
// Tier 3 — Heavy: execution, replanning (requires deep reasoning)
|
||||
// Tier 3 — Heavy: planning, execution, replanning (requires deep reasoning)
|
||||
// Planning is heavy so it uses the best configured model (e.g. Opus) and is
|
||||
// not downgraded by dynamic routing when a capable model is configured.
|
||||
"plan-milestone": "heavy",
|
||||
"plan-slice": "heavy",
|
||||
"execute-task": "standard", // default standard, upgraded by metadata
|
||||
"replan-slice": "heavy",
|
||||
"reassess-roadmap": "heavy",
|
||||
|
|
@ -185,8 +187,8 @@ function analyzePlanComplexity(
|
|||
// Check if this is a milestone-level plan (more complex) vs single slice
|
||||
const { milestone: mid, slice: sid } = parseUnitId(unitId);
|
||||
if (!sid) {
|
||||
// Milestone-level planning is always at least standard
|
||||
return { tier: "standard", reason: "milestone-level planning" };
|
||||
// Milestone-level planning is always heavy — requires full context and best model
|
||||
return { tier: "heavy", reason: "milestone-level planning" };
|
||||
}
|
||||
|
||||
// For slice planning, try to read the context/research to gauge complexity
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ export const GSD_ROOT_FILES = {
|
|||
REQUIREMENTS: "REQUIREMENTS.md",
|
||||
OVERRIDES: "OVERRIDES.md",
|
||||
KNOWLEDGE: "KNOWLEDGE.md",
|
||||
CODEBASE: "CODEBASE.md",
|
||||
} as const;
|
||||
|
||||
export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES;
|
||||
|
|
@ -276,6 +277,7 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
|
|||
REQUIREMENTS: "requirements.md",
|
||||
OVERRIDES: "overrides.md",
|
||||
KNOWLEDGE: "knowledge.md",
|
||||
CODEBASE: "codebase.md",
|
||||
};
|
||||
|
||||
// ─── GSD Root Discovery ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -228,15 +228,36 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|||
const stopTimer = debugTime("derive-state-impl");
|
||||
let result: GSDState;
|
||||
|
||||
// Dual-path: try DB-backed derivation when DB is available.
|
||||
// Always go through deriveStateFromDb when DB is open — even if hierarchy
|
||||
// tables are empty — because it contains disk→DB reconciliation logic that
|
||||
// discovers milestones created outside the DB write path (#2631).
|
||||
// Dual-path: try DB-backed derivation first when hierarchy tables are populated
|
||||
if (isDbAvailable()) {
|
||||
const stopDbTimer = debugTime("derive-state-db");
|
||||
result = await deriveStateFromDb(basePath);
|
||||
stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
|
||||
_telemetry.dbDeriveCount++;
|
||||
let dbMilestones = getAllMilestones();
|
||||
|
||||
// Disk→DB reconciliation when DB is empty but disk has milestones (#2631).
|
||||
// deriveStateFromDb() does its own reconciliation, but deriveState() skips
|
||||
// it entirely when the DB is empty. Sync here so the DB path is used when
|
||||
// disk milestones exist but haven't been migrated yet.
|
||||
if (dbMilestones.length === 0) {
|
||||
const diskIds = findMilestoneIds(basePath);
|
||||
let synced = false;
|
||||
for (const diskId of diskIds) {
|
||||
if (!isGhostMilestone(basePath, diskId)) {
|
||||
insertMilestone({ id: diskId, status: 'active' });
|
||||
synced = true;
|
||||
}
|
||||
}
|
||||
if (synced) dbMilestones = getAllMilestones();
|
||||
}
|
||||
|
||||
if (dbMilestones.length > 0) {
|
||||
const stopDbTimer = debugTime("derive-state-db");
|
||||
result = await deriveStateFromDb(basePath);
|
||||
stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
|
||||
_telemetry.dbDeriveCount++;
|
||||
} else {
|
||||
// DB open but no milestones on disk either — use filesystem path
|
||||
result = await _deriveStateImpl(basePath);
|
||||
_telemetry.markdownDeriveCount++;
|
||||
}
|
||||
} else {
|
||||
result = await _deriveStateImpl(basePath);
|
||||
_telemetry.markdownDeriveCount++;
|
||||
|
|
|
|||
488
src/resources/extensions/gsd/tests/codebase-generator.test.ts
Normal file
488
src/resources/extensions/gsd/tests/codebase-generator.test.ts
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
parseCodebaseMap,
|
||||
generateCodebaseMap,
|
||||
updateCodebaseMap,
|
||||
writeCodebaseMap,
|
||||
readCodebaseMap,
|
||||
getCodebaseMapStats,
|
||||
} from "../codebase-generator.ts";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpRepo(): string {
|
||||
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
|
||||
mkdirSync(join(base, ".gsd"), { recursive: true });
|
||||
execSync("git init", { cwd: base, stdio: "ignore" });
|
||||
return base;
|
||||
}
|
||||
|
||||
function addFile(base: string, path: string, content = ""): void {
|
||||
const fullPath = join(base, path);
|
||||
mkdirSync(join(fullPath, ".."), { recursive: true });
|
||||
writeFileSync(fullPath, content || `// ${path}\n`, "utf-8");
|
||||
execSync(`git add "${path}"`, { cwd: base, stdio: "ignore" });
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
||||
}
|
||||
|
||||
// ─── parseCodebaseMap ────────────────────────────────────────────────────
|
||||
|
||||
test("parseCodebaseMap: parses file with description", () => {
|
||||
const content = `# Codebase Map
|
||||
|
||||
### src/
|
||||
- \`main.ts\` — Application entry point
|
||||
- \`utils.ts\` — Shared utilities
|
||||
`;
|
||||
|
||||
const map = parseCodebaseMap(content);
|
||||
assert.equal(map.size, 2);
|
||||
assert.equal(map.get("main.ts"), "Application entry point");
|
||||
assert.equal(map.get("utils.ts"), "Shared utilities");
|
||||
});
|
||||
|
||||
test("parseCodebaseMap: parses file without description", () => {
|
||||
const content = `- \`config.ts\`\n- \`index.ts\` — Entry\n`;
|
||||
const map = parseCodebaseMap(content);
|
||||
assert.equal(map.size, 2);
|
||||
assert.equal(map.get("config.ts"), "");
|
||||
assert.equal(map.get("index.ts"), "Entry");
|
||||
});
|
||||
|
||||
test("parseCodebaseMap: empty content returns empty map", () => {
|
||||
const map = parseCodebaseMap("");
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
|
||||
test("parseCodebaseMap: ignores non-matching lines", () => {
|
||||
const content = `# Codebase Map\n\nGenerated: 2026-03-23\n\n### src/\n- \`file.ts\` — desc\n`;
|
||||
const map = parseCodebaseMap(content);
|
||||
assert.equal(map.size, 1);
|
||||
});
|
||||
|
||||
test("parseCodebaseMap: recovers descriptions from collapsed-description comments", () => {
|
||||
const content = `# Codebase Map
|
||||
|
||||
### src/components/
|
||||
- *(25 files: 25 .ts)*
|
||||
<!-- gsd:collapsed-descriptions
|
||||
- \`src/components/Foo.ts\` — The Foo component
|
||||
- \`src/components/Bar.ts\` — The Bar component
|
||||
-->
|
||||
`;
|
||||
const map = parseCodebaseMap(content);
|
||||
assert.equal(map.get("src/components/Foo.ts"), "The Foo component");
|
||||
assert.equal(map.get("src/components/Bar.ts"), "The Bar component");
|
||||
// The collapsed summary line itself should not be parsed as a file
|
||||
assert.ok(!map.has("*(25 files: 25 .ts)*"));
|
||||
});
|
||||
|
||||
test("parseCodebaseMap: handles corrupted/malformed input gracefully", () => {
|
||||
const content = [
|
||||
"- `unclosed backtick",
|
||||
"- `` — empty filename",
|
||||
"- `valid.ts` — ok",
|
||||
"random garbage line",
|
||||
"- `a.ts` — desc with other text",
|
||||
].join("\n");
|
||||
const map = parseCodebaseMap(content);
|
||||
assert.ok(map.has("valid.ts"));
|
||||
assert.ok(map.has("a.ts"));
|
||||
// Malformed lines should be silently skipped
|
||||
assert.equal(map.size, 2);
|
||||
});
|
||||
|
||||
// ─── generateCodebaseMap ─────────────────────────────────────────────────
|
||||
|
||||
test("generateCodebaseMap: generates from git ls-files", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "src/utils.ts");
|
||||
addFile(base, "README.md");
|
||||
|
||||
const result = generateCodebaseMap(base);
|
||||
assert.ok(result.content.includes("# Codebase Map"));
|
||||
assert.ok(result.content.includes("`src/main.ts`"));
|
||||
assert.ok(result.content.includes("`src/utils.ts`"));
|
||||
assert.ok(result.content.includes("README.md"));
|
||||
assert.equal(result.fileCount, 3);
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(result.files.length, 3);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: excludes .gsd/ files", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, ".gsd/PROJECT.md");
|
||||
|
||||
const result = generateCodebaseMap(base);
|
||||
assert.ok(result.content.includes("`src/main.ts`"));
|
||||
assert.ok(!result.content.includes("PROJECT.md"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: excludes binary and lock files", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "package-lock.json"); // .json not excluded
|
||||
addFile(base, "yarn.lock"); // .lock excluded
|
||||
addFile(base, "assets/logo.png"); // .png excluded
|
||||
|
||||
const result = generateCodebaseMap(base);
|
||||
assert.ok(result.content.includes("`src/main.ts`"));
|
||||
assert.ok(result.content.includes("package-lock.json"));
|
||||
assert.ok(!result.content.includes("yarn.lock"));
|
||||
assert.ok(!result.content.includes("logo.png"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: respects custom excludePatterns", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "docs/guide.md");
|
||||
addFile(base, "docs/api.md");
|
||||
|
||||
const result = generateCodebaseMap(base, { excludePatterns: ["docs/"] });
|
||||
assert.ok(result.content.includes("`src/main.ts`"));
|
||||
assert.ok(!result.content.includes("guide.md"));
|
||||
assert.ok(!result.content.includes("api.md"));
|
||||
assert.equal(result.fileCount, 1);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: preserves existing descriptions", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "src/utils.ts");
|
||||
|
||||
const descriptions = new Map<string, string>();
|
||||
descriptions.set("src/main.ts", "App entry point");
|
||||
|
||||
const result = generateCodebaseMap(base, undefined, descriptions);
|
||||
assert.ok(result.content.includes("`src/main.ts` — App entry point"));
|
||||
assert.ok(result.content.includes("`src/utils.ts`"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: collapses large directories", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`);
|
||||
}
|
||||
|
||||
const result = generateCodebaseMap(base);
|
||||
// Collapsed summary should appear
|
||||
assert.ok(result.content.includes("*(25 files: 25 .ts)*"));
|
||||
// Individual file entries should NOT appear in main body
|
||||
assert.ok(!result.content.includes("`src/components/comp00.ts`\n"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: respects custom collapseThreshold", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 5; i++) addFile(base, `src/comp${i}.ts`);
|
||||
|
||||
// Low threshold: 5 files should collapse
|
||||
const collapsed = generateCodebaseMap(base, { collapseThreshold: 3 });
|
||||
assert.ok(collapsed.content.includes("5 files"));
|
||||
|
||||
// High threshold: 5 files should expand
|
||||
const expanded = generateCodebaseMap(base, { collapseThreshold: 10 });
|
||||
assert.ok(expanded.content.includes("`src/comp0.ts`"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: truncated=false when file count is below maxFiles", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 4; i++) addFile(base, `file${i}.ts`);
|
||||
const result = generateCodebaseMap(base, { maxFiles: 5 });
|
||||
assert.equal(result.fileCount, 4);
|
||||
assert.equal(result.truncated, false);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: truncated=false when file count equals maxFiles exactly", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 5; i++) addFile(base, `file${i}.ts`);
|
||||
const result = generateCodebaseMap(base, { maxFiles: 5 });
|
||||
assert.equal(result.fileCount, 5);
|
||||
assert.equal(result.truncated, false); // exactly at limit — nothing was truncated
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: truncated=true when file count exceeds maxFiles", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`);
|
||||
const result = generateCodebaseMap(base, { maxFiles: 5 });
|
||||
assert.equal(result.fileCount, 5);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(result.content.includes("Truncated"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: returns empty map for non-git directory", () => {
|
||||
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
|
||||
mkdirSync(join(base, ".gsd"), { recursive: true });
|
||||
// No git init
|
||||
try {
|
||||
const result = generateCodebaseMap(base);
|
||||
assert.equal(result.fileCount, 0);
|
||||
assert.equal(result.truncated, false);
|
||||
assert.ok(result.content.includes("# Codebase Map"));
|
||||
assert.equal(result.files.length, 0);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: handles empty repository (no committed files)", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
const result = generateCodebaseMap(base);
|
||||
assert.equal(result.fileCount, 0);
|
||||
assert.equal(result.truncated, false);
|
||||
assert.ok(result.content.includes("Files: 0"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: collapsed directories preserve descriptions in hidden comment", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`);
|
||||
}
|
||||
|
||||
// Generate with a description for one file in the collapsed dir
|
||||
const descriptions = new Map([["src/components/comp00.ts", "The first component"]]);
|
||||
const result = generateCodebaseMap(base, undefined, descriptions);
|
||||
|
||||
// The description should be in the hidden comment block
|
||||
assert.ok(result.content.includes("<!-- gsd:collapsed-descriptions"));
|
||||
assert.ok(result.content.includes("`src/components/comp00.ts` — The first component"));
|
||||
|
||||
// Re-parsing should recover the description
|
||||
const recovered = parseCodebaseMap(result.content);
|
||||
assert.equal(recovered.get("src/components/comp00.ts"), "The first component");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── updateCodebaseMap ───────────────────────────────────────────────────
|
||||
|
||||
test("updateCodebaseMap: preserves descriptions on update", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "src/utils.ts");
|
||||
|
||||
const initial = generateCodebaseMap(base, undefined, new Map([["src/main.ts", "Entry point"]]));
|
||||
writeCodebaseMap(base, initial.content);
|
||||
|
||||
addFile(base, "src/new.ts");
|
||||
|
||||
const result = updateCodebaseMap(base);
|
||||
assert.ok(result.content.includes("`src/main.ts` — Entry point"));
|
||||
assert.equal(result.added, 1);
|
||||
assert.equal(result.fileCount, 3);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("updateCodebaseMap: tracks removed files", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/keep.ts");
|
||||
addFile(base, "src/remove.ts");
|
||||
// Commit so git rm can operate
|
||||
execSync("git -c user.email=t@t.com -c user.name=T commit -m init", { cwd: base, stdio: "ignore" });
|
||||
|
||||
const initial = generateCodebaseMap(base);
|
||||
writeCodebaseMap(base, initial.content);
|
||||
|
||||
execSync("git rm src/remove.ts", { cwd: base, stdio: "ignore" });
|
||||
|
||||
const result = updateCodebaseMap(base);
|
||||
assert.equal(result.removed, 1);
|
||||
assert.equal(result.unchanged, 1);
|
||||
assert.equal(result.fileCount, 1);
|
||||
assert.ok(!result.content.includes("remove.ts"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("updateCodebaseMap: propagates truncated flag", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`);
|
||||
|
||||
const initial = generateCodebaseMap(base, { maxFiles: 5 });
|
||||
writeCodebaseMap(base, initial.content);
|
||||
|
||||
const result = updateCodebaseMap(base, { maxFiles: 5 });
|
||||
assert.equal(result.truncated, true);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("updateCodebaseMap: preserves descriptions from collapsed directories", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`);
|
||||
}
|
||||
|
||||
// Generate with a description in the (collapsed) components dir
|
||||
const descriptions = new Map([["src/components/comp00.ts", "The first component"]]);
|
||||
const initial = generateCodebaseMap(base, undefined, descriptions);
|
||||
writeCodebaseMap(base, initial.content);
|
||||
|
||||
// Update should recover description from the hidden comment
|
||||
const result = updateCodebaseMap(base);
|
||||
const recovered = parseCodebaseMap(result.content);
|
||||
assert.equal(recovered.get("src/components/comp00.ts"), "The first component");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── writeCodebaseMap / readCodebaseMap ──────────────────────────────────
|
||||
|
||||
test("writeCodebaseMap + readCodebaseMap roundtrip", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
const content = "# Codebase Map\n\n- `test.ts` — A test file\n";
|
||||
const outPath = writeCodebaseMap(base, content);
|
||||
assert.ok(existsSync(outPath));
|
||||
|
||||
const read = readCodebaseMap(base);
|
||||
assert.equal(read, content);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("readCodebaseMap: returns null when file missing", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
const result = readCodebaseMap(base);
|
||||
assert.equal(result, null);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("writeCodebaseMap: creates .gsd/ directory if missing", () => {
|
||||
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
|
||||
mkdirSync(base, { recursive: true });
|
||||
// Intentionally do NOT pre-create .gsd/
|
||||
try {
|
||||
const outPath = writeCodebaseMap(base, "# Codebase Map\n");
|
||||
assert.ok(existsSync(outPath));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── getCodebaseMapStats ─────────────────────────────────────────────────
|
||||
|
||||
test("getCodebaseMapStats: no map returns exists=false", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
const stats = getCodebaseMapStats(base);
|
||||
assert.equal(stats.exists, false);
|
||||
assert.equal(stats.fileCount, 0);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("getCodebaseMapStats: reports coverage", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
const content = `# Codebase Map\n\nGenerated: 2026-03-23T14:00:00Z | Files: 3 | Described: 2/3\n\n- \`a.ts\` — Has desc\n- \`b.ts\`\n- \`c.ts\` — Also has\n`;
|
||||
writeCodebaseMap(base, content);
|
||||
|
||||
const stats = getCodebaseMapStats(base);
|
||||
assert.equal(stats.exists, true);
|
||||
assert.equal(stats.fileCount, 3); // from header, not parse count
|
||||
assert.equal(stats.describedCount, 2);
|
||||
assert.equal(stats.undescribedCount, 1);
|
||||
assert.equal(stats.generatedAt, "2026-03-23T14:00:00Z");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("getCodebaseMapStats: reads total file count from header for accuracy with collapsed dirs", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
// Simulate a map with a collapsed dir: header says 30 files but parser only sees 2
|
||||
const content = [
|
||||
"# Codebase Map",
|
||||
"",
|
||||
"Generated: 2026-03-23T14:00:00Z | Files: 30 | Described: 2/30",
|
||||
"",
|
||||
"### src/components/",
|
||||
"- *(28 files: 28 .ts)*",
|
||||
"",
|
||||
"### src/",
|
||||
"- `main.ts` — Entry point",
|
||||
"- `utils.ts` — Utilities",
|
||||
].join("\n");
|
||||
writeCodebaseMap(base, content);
|
||||
|
||||
const stats = getCodebaseMapStats(base);
|
||||
assert.equal(stats.fileCount, 30); // from header, not from parseCodebaseMap
|
||||
assert.equal(stats.describedCount, 2);
|
||||
assert.equal(stats.undescribedCount, 28);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
|
@ -41,14 +41,14 @@ test("research-slice classifies as standard", () => {
|
|||
assert.equal(result.tier, "standard");
|
||||
});
|
||||
|
||||
test("plan-milestone classifies as standard", () => {
|
||||
test("plan-milestone classifies as heavy", () => {
|
||||
const result = classifyUnitComplexity("plan-milestone", "M001", "/tmp/fake");
|
||||
assert.equal(result.tier, "standard");
|
||||
assert.equal(result.tier, "heavy");
|
||||
});
|
||||
|
||||
test("plan-slice classifies as standard", () => {
|
||||
test("plan-slice classifies as heavy", () => {
|
||||
const result = classifyUnitComplexity("plan-slice", "M001/S01", "/tmp/fake");
|
||||
assert.equal(result.tier, "standard");
|
||||
assert.equal(result.tier, "heavy");
|
||||
});
|
||||
|
||||
test("replan-slice classifies as heavy", () => {
|
||||
|
|
|
|||
|
|
@ -739,6 +739,39 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("#2912: stale SQUASH_MSG and MERGE_MSG are cleaned before squash merge", () => {
|
||||
// Verifies that the pre-merge cleanup (step 7b) removes all three merge
|
||||
// artifacts — not just MERGE_HEAD — so that `git merge --squash` never
|
||||
// encounters leftover state from a prior interrupted operation.
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M294");
|
||||
|
||||
addSliceToMilestone(repo, wtPath, "M294", "S01", "Feature C", [
|
||||
{ file: "feature-c.ts", content: "export const c = true;\n", message: "add feature c" },
|
||||
]);
|
||||
|
||||
const roadmap = makeRoadmap("M294", "Stale merge artifacts", [
|
||||
{ id: "S01", title: "Feature C" },
|
||||
]);
|
||||
|
||||
// Plant stale merge artifacts in the git dir to simulate a prior
|
||||
// interrupted merge. The pre-merge cleanup must remove all of them.
|
||||
const gitDir = join(repo, ".git");
|
||||
writeFileSync(join(gitDir, "SQUASH_MSG"), "stale squash message\n");
|
||||
writeFileSync(join(gitDir, "MERGE_MSG"), "stale merge message\n");
|
||||
|
||||
mergeMilestoneToMain(repo, "M294", roadmap);
|
||||
|
||||
assert.ok(
|
||||
!existsSync(join(gitDir, "SQUASH_MSG")),
|
||||
"#2912: stale SQUASH_MSG must be removed by pre-merge cleanup",
|
||||
);
|
||||
assert.ok(
|
||||
!existsSync(join(gitDir, "MERGE_MSG")),
|
||||
"#2912: stale MERGE_MSG must be removed by pre-merge cleanup",
|
||||
);
|
||||
});
|
||||
|
||||
test("#1906: codeFilesChanged=true when real code is merged", () => {
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M190");
|
||||
|
|
|
|||
130
src/resources/extensions/ollama/index.ts
Normal file
130
src/resources/extensions/ollama/index.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// GSD2 — Ollama Extension: First-class local LLM support
|
||||
/**
|
||||
* Ollama Extension
|
||||
*
|
||||
* Auto-detects a running Ollama instance, discovers locally pulled models,
|
||||
* and registers them as a first-class provider. No configuration required —
|
||||
* if Ollama is running, models appear automatically.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-discovery of local models via /api/tags
|
||||
* - Capability detection (vision, reasoning, context window)
|
||||
* - /ollama slash commands for model management
|
||||
* - ollama_manage tool for LLM-driven model operations
|
||||
* - Zero-cost model registration (local inference)
|
||||
*
|
||||
* Respects OLLAMA_HOST env var for non-default endpoints.
|
||||
*/
|
||||
|
||||
import { importExtensionModule, type ExtensionAPI } from "@gsd/pi-coding-agent";
|
||||
import type { OpenAICompletionsCompat } from "@gsd/pi-ai";
|
||||
import * as client from "./ollama-client.js";
|
||||
import { discoverModels, getOllamaOpenAIBaseUrl } from "./ollama-discovery.js";
|
||||
import { registerOllamaCommands } from "./ollama-commands.js";
|
||||
|
||||
/** Default compat settings for Ollama models via OpenAI-compat endpoint */
|
||||
const OLLAMA_COMPAT: OpenAICompletionsCompat = {
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsUsageInStreaming: false,
|
||||
maxTokensField: "max_tokens",
|
||||
supportsStore: false,
|
||||
};
|
||||
|
||||
let toolsPromise: Promise<void> | null = null;
|
||||
|
||||
async function registerOllamaTools(pi: ExtensionAPI): Promise<void> {
|
||||
if (!toolsPromise) {
|
||||
toolsPromise = (async () => {
|
||||
const { registerOllamaTool } = await importExtensionModule<
|
||||
typeof import("./ollama-tool.js")
|
||||
>(import.meta.url, "./ollama-tool.js");
|
||||
registerOllamaTool(pi);
|
||||
})().catch((error) => {
|
||||
toolsPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return toolsPromise;
|
||||
}
|
||||
|
||||
/** Track whether we've registered models so we can clean up on shutdown */
|
||||
let providerRegistered = false;
|
||||
|
||||
/**
|
||||
* Probe Ollama and register discovered models.
|
||||
* Safe to call multiple times — re-discovers and re-registers.
|
||||
*/
|
||||
async function probeAndRegister(pi: ExtensionAPI): Promise<boolean> {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
if (providerRegistered) {
|
||||
pi.unregisterProvider("ollama");
|
||||
providerRegistered = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const models = await discoverModels();
|
||||
if (models.length === 0) return true; // Running but no models pulled
|
||||
|
||||
const baseUrl = getOllamaOpenAIBaseUrl();
|
||||
|
||||
pi.registerProvider("ollama", {
|
||||
authMode: "none",
|
||||
baseUrl,
|
||||
api: "openai-completions",
|
||||
isReady: () => true,
|
||||
models: models.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
reasoning: m.reasoning,
|
||||
input: m.input,
|
||||
cost: m.cost,
|
||||
contextWindow: m.contextWindow,
|
||||
maxTokens: m.maxTokens,
|
||||
compat: OLLAMA_COMPAT,
|
||||
})),
|
||||
});
|
||||
|
||||
providerRegistered = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function ollama(pi: ExtensionAPI) {
|
||||
// Register slash commands immediately (they check Ollama availability themselves)
|
||||
registerOllamaCommands(pi);
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// Register tool (deferred to avoid blocking startup)
|
||||
if (ctx.hasUI) {
|
||||
void registerOllamaTools(pi).catch((error) => {
|
||||
ctx.ui.notify(
|
||||
`Ollama tool failed to load: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await registerOllamaTools(pi);
|
||||
}
|
||||
|
||||
// Async probe — don't block startup
|
||||
probeAndRegister(pi)
|
||||
.then((found) => {
|
||||
if (found && ctx.hasUI) {
|
||||
ctx.ui.setStatus("ollama", "Ollama");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore probe failures
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (providerRegistered) {
|
||||
pi.unregisterProvider("ollama");
|
||||
providerRegistered = false;
|
||||
}
|
||||
toolsPromise = null;
|
||||
});
|
||||
}
|
||||
145
src/resources/extensions/ollama/model-capabilities.ts
Normal file
145
src/resources/extensions/ollama/model-capabilities.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// GSD2 — Known model capability table for Ollama models
|
||||
|
||||
/**
|
||||
* Maps well-known Ollama model families to their capabilities.
|
||||
* Used to enrich auto-discovered models with accurate context windows,
|
||||
* vision support, and reasoning detection.
|
||||
*
|
||||
* Fallback: estimate from parameter count if model isn't in the table.
|
||||
*/
|
||||
|
||||
export interface ModelCapability {
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
input?: ("text" | "image")[];
|
||||
reasoning?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known model family capabilities.
|
||||
* Keys are matched as prefixes against the model name (before the colon/tag).
|
||||
* More specific entries should appear first.
|
||||
*/
|
||||
const KNOWN_MODELS: Array<[pattern: string, caps: ModelCapability]> = [
|
||||
// ─── Reasoning models ───────────────────────────────────────────────
|
||||
["deepseek-r1", { contextWindow: 131072, reasoning: true }],
|
||||
["qwq", { contextWindow: 131072, reasoning: true }],
|
||||
|
||||
// ─── Vision models ──────────────────────────────────────────────────
|
||||
["llava", { contextWindow: 4096, input: ["text", "image"] }],
|
||||
["bakllava", { contextWindow: 4096, input: ["text", "image"] }],
|
||||
["moondream", { contextWindow: 8192, input: ["text", "image"] }],
|
||||
["llama3.2-vision", { contextWindow: 131072, input: ["text", "image"] }],
|
||||
["minicpm-v", { contextWindow: 4096, input: ["text", "image"] }],
|
||||
|
||||
// ─── Code models ────────────────────────────────────────────────────
|
||||
["codestral", { contextWindow: 262144, maxTokens: 32768 }],
|
||||
["qwen2.5-coder", { contextWindow: 131072, maxTokens: 32768 }],
|
||||
["deepseek-coder-v2", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["starcoder2", { contextWindow: 16384, maxTokens: 8192 }],
|
||||
["codegemma", { contextWindow: 8192, maxTokens: 8192 }],
|
||||
["codellama", { contextWindow: 16384, maxTokens: 8192 }],
|
||||
["devstral", { contextWindow: 131072, maxTokens: 32768 }],
|
||||
|
||||
// ─── Llama family ───────────────────────────────────────────────────
|
||||
["llama3.3", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["llama3.2", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["llama3.1", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["llama3", { contextWindow: 8192, maxTokens: 8192 }],
|
||||
["llama2", { contextWindow: 4096, maxTokens: 4096 }],
|
||||
|
||||
// ─── Qwen family ────────────────────────────────────────────────────
|
||||
["qwen3", { contextWindow: 131072, maxTokens: 32768 }],
|
||||
["qwen2.5", { contextWindow: 131072, maxTokens: 32768 }],
|
||||
["qwen2", { contextWindow: 131072, maxTokens: 32768 }],
|
||||
|
||||
// ─── Gemma family ───────────────────────────────────────────────────
|
||||
["gemma3", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["gemma2", { contextWindow: 8192, maxTokens: 8192 }],
|
||||
|
||||
// ─── Mistral family ─────────────────────────────────────────────────
|
||||
["mistral-large", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["mistral-small", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["mistral-nemo", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["mistral", { contextWindow: 32768, maxTokens: 8192 }],
|
||||
["mixtral", { contextWindow: 32768, maxTokens: 8192 }],
|
||||
|
||||
// ─── Phi family ─────────────────────────────────────────────────────
|
||||
["phi4", { contextWindow: 16384, maxTokens: 16384 }],
|
||||
["phi3.5", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["phi3", { contextWindow: 131072, maxTokens: 4096 }],
|
||||
|
||||
// ─── Command R ──────────────────────────────────────────────────────
|
||||
["command-r-plus", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
["command-r", { contextWindow: 131072, maxTokens: 16384 }],
|
||||
];
|
||||
|
||||
/**
|
||||
* Look up capabilities for a model by name.
|
||||
* Matches the longest prefix from the known models table.
|
||||
*/
|
||||
export function getModelCapabilities(modelName: string): ModelCapability {
|
||||
// Strip tag (everything after the colon) for matching
|
||||
const baseName = modelName.split(":")[0].toLowerCase();
|
||||
|
||||
for (const [pattern, caps] of KNOWN_MODELS) {
|
||||
if (baseName === pattern || baseName.startsWith(pattern)) {
|
||||
return caps;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate context window from parameter size string (e.g. "7B", "70B", "1.5B").
|
||||
* Used as fallback when model isn't in the known table.
|
||||
*/
|
||||
export function estimateContextFromParams(parameterSize: string): number {
|
||||
const match = parameterSize.match(/([\d.]+)\s*([BbMm])/);
|
||||
if (!match) return 8192;
|
||||
|
||||
const size = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
|
||||
// Convert to billions
|
||||
const billions = unit === "M" ? size / 1000 : size;
|
||||
|
||||
// Rough heuristics: larger models tend to support larger contexts
|
||||
if (billions >= 70) return 131072;
|
||||
if (billions >= 30) return 65536;
|
||||
if (billions >= 13) return 32768;
|
||||
if (billions >= 7) return 16384;
|
||||
return 8192;
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanize a model name for display (e.g. "llama3.1:8b" → "Llama 3.1 8B").
|
||||
*/
|
||||
export function humanizeModelName(modelName: string): string {
|
||||
const [base, tag] = modelName.split(":");
|
||||
|
||||
// Capitalize first letter, add spaces around version numbers
|
||||
let name = base
|
||||
.replace(/([a-z])(\d)/g, "$1 $2")
|
||||
.replace(/(\d)([a-z])/g, "$1 $2")
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
// Clean up common patterns
|
||||
name = name.replace(/\s*-\s*/g, " ");
|
||||
|
||||
if (tag && tag !== "latest") {
|
||||
name += ` ${tag.toUpperCase()}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte size for display (e.g. 4700000000 → "4.7 GB").
|
||||
*/
|
||||
export function formatModelSize(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
return `${(bytes / 1e3).toFixed(0)} KB`;
|
||||
}
|
||||
196
src/resources/extensions/ollama/ollama-client.ts
Normal file
196
src/resources/extensions/ollama/ollama-client.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// GSD2 — HTTP client for Ollama REST API
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for the Ollama REST API.
|
||||
* Respects the OLLAMA_HOST environment variable for non-default endpoints.
|
||||
*
|
||||
* Reference: https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
*/
|
||||
|
||||
import type {
|
||||
OllamaPsResponse,
|
||||
OllamaPullProgress,
|
||||
OllamaShowResponse,
|
||||
OllamaTagsResponse,
|
||||
OllamaVersionResponse,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_HOST = "http://localhost:11434";
|
||||
const PROBE_TIMEOUT_MS = 1500;
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Get the Ollama host URL from OLLAMA_HOST or default.
|
||||
*/
|
||||
export function getOllamaHost(): string {
|
||||
const host = process.env.OLLAMA_HOST;
|
||||
if (!host) return DEFAULT_HOST;
|
||||
|
||||
// OLLAMA_HOST can be just a host:port without scheme
|
||||
if (host.startsWith("http://") || host.startsWith("https://")) return host;
|
||||
return `http://${host}`;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = REQUEST_TIMEOUT_MS): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Ollama is running and reachable.
|
||||
*/
|
||||
export async function isRunning(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/`, {}, PROBE_TIMEOUT_MS);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Ollama version.
|
||||
*/
|
||||
export async function getVersion(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/api/version`);
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as OllamaVersionResponse;
|
||||
return data.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all locally available models.
|
||||
*/
|
||||
export async function listModels(): Promise<OllamaTagsResponse> {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/api/tags`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama /api/tags returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as OllamaTagsResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific model.
|
||||
*/
|
||||
export async function showModel(name: string): Promise<OllamaShowResponse> {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama /api/show returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as OllamaShowResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* List currently loaded/running models.
|
||||
*/
|
||||
export async function getRunningModels(): Promise<OllamaPsResponse> {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/api/ps`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama /api/ps returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as OllamaPsResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a model with streaming progress.
|
||||
* Calls onProgress for each progress update.
|
||||
* Returns when the pull is complete.
|
||||
*/
|
||||
export async function pullModel(
|
||||
name: string,
|
||||
onProgress?: (progress: OllamaPullProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${getOllamaHost()}/api/pull`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, stream: true }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Ollama /api/pull returned ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Ollama /api/pull returned no body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const progress = JSON.parse(trimmed) as OllamaPullProgress;
|
||||
onProgress?.(progress);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const progress = JSON.parse(buffer.trim()) as OllamaPullProgress;
|
||||
onProgress?.(progress);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a local model.
|
||||
*/
|
||||
export async function deleteModel(name: string): Promise<void> {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/api/delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Ollama /api/delete returned ${response.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a model to a new name.
|
||||
*/
|
||||
export async function copyModel(source: string, destination: string): Promise<void> {
|
||||
const response = await fetchWithTimeout(`${getOllamaHost()}/api/copy`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source, destination }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Ollama /api/copy returned ${response.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
248
src/resources/extensions/ollama/ollama-commands.ts
Normal file
248
src/resources/extensions/ollama/ollama-commands.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// GSD2 — Ollama slash commands
|
||||
|
||||
/**
|
||||
* Registers /ollama slash commands for managing local Ollama models.
|
||||
*
|
||||
* Commands:
|
||||
* /ollama — Show status (running?, version, loaded models)
|
||||
* /ollama list — List all available local models with sizes
|
||||
* /ollama pull — Pull a model with progress
|
||||
* /ollama remove — Delete a local model
|
||||
* /ollama ps — Show running models and resource usage
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
||||
import { Text } from "@gsd/pi-tui";
|
||||
import * as client from "./ollama-client.js";
|
||||
import { discoverModels, formatModelForDisplay } from "./ollama-discovery.js";
|
||||
import { formatModelSize } from "./model-capabilities.js";
|
||||
|
||||
export function registerOllamaCommands(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("ollama", {
|
||||
description: "Manage local Ollama models — list | pull | remove | ps",
|
||||
async handler(args, ctx) {
|
||||
const parts = (args ?? "").trim().split(/\s+/);
|
||||
const subcommand = parts[0] || "status";
|
||||
const modelArg = parts.slice(1).join(" ");
|
||||
|
||||
switch (subcommand) {
|
||||
case "status":
|
||||
return await handleStatus(ctx);
|
||||
case "list":
|
||||
case "ls":
|
||||
return await handleList(ctx);
|
||||
case "pull":
|
||||
return await handlePull(modelArg, ctx);
|
||||
case "remove":
|
||||
case "rm":
|
||||
case "delete":
|
||||
return await handleRemove(modelArg, ctx);
|
||||
case "ps":
|
||||
return await handlePs(ctx);
|
||||
default:
|
||||
ctx.ui.notify(
|
||||
`Unknown subcommand: ${subcommand}. Use: status, list, pull, remove, ps`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStatus(ctx: any): Promise<void> {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
ctx.ui.notify(
|
||||
"Ollama is not running. Install from https://ollama.com and run 'ollama serve'",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const version = await client.getVersion();
|
||||
const lines: string[] = [];
|
||||
lines.push(`Ollama${version ? ` v${version}` : ""} — running (${client.getOllamaHost()})`);
|
||||
|
||||
// Show loaded models
|
||||
try {
|
||||
const ps = await client.getRunningModels();
|
||||
if (ps.models && ps.models.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Loaded:");
|
||||
for (const m of ps.models) {
|
||||
const vram = m.size_vram > 0 ? formatModelSize(m.size_vram) + " VRAM" : "CPU";
|
||||
const expiresAt = new Date(m.expires_at);
|
||||
const idleMs = expiresAt.getTime() - Date.now();
|
||||
const idleMin = Math.max(0, Math.floor(idleMs / 60000));
|
||||
lines.push(` ${m.name} ${vram} expires in ${idleMin}m`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ps endpoint may not be available on older versions
|
||||
}
|
||||
|
||||
// Show available models
|
||||
try {
|
||||
const models = await discoverModels();
|
||||
if (models.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Available:");
|
||||
for (const m of models) {
|
||||
lines.push(` ${formatModelForDisplay(m)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("");
|
||||
lines.push("No models pulled. Use /ollama pull <model> to get started.");
|
||||
}
|
||||
} catch (err) {
|
||||
lines.push("");
|
||||
lines.push(`Error listing models: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
await ctx.ui.custom(
|
||||
(tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
|
||||
const text = new Text(lines.map((l) => theme.fg("fg", l)).join("\n"), 0, 0);
|
||||
setTimeout(() => done(undefined), 0);
|
||||
return text;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleList(ctx: any): Promise<void> {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
ctx.ui.notify("Ollama is not running", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const models = await discoverModels();
|
||||
if (models.length === 0) {
|
||||
ctx.ui.notify("No models available. Use /ollama pull <model> to download one.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = ["Local Ollama models:", ""];
|
||||
for (const m of models) {
|
||||
lines.push(` ${formatModelForDisplay(m)}`);
|
||||
}
|
||||
|
||||
await ctx.ui.custom(
|
||||
(tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
|
||||
const text = new Text(lines.map((l) => theme.fg("fg", l)).join("\n"), 0, 0);
|
||||
setTimeout(() => done(undefined), 0);
|
||||
return text;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePull(modelName: string, ctx: any): Promise<void> {
|
||||
if (!modelName) {
|
||||
ctx.ui.notify("Usage: /ollama pull <model> (e.g. /ollama pull llama3.1:8b)", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
ctx.ui.notify("Ollama is not running", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setWidget("ollama-pull", [`Pulling ${modelName}...`]);
|
||||
|
||||
try {
|
||||
let lastPercent = -1;
|
||||
await client.pullModel(modelName, (progress) => {
|
||||
if (progress.total && progress.completed) {
|
||||
const percent = Math.floor((progress.completed / progress.total) * 100);
|
||||
if (percent !== lastPercent) {
|
||||
lastPercent = percent;
|
||||
const completed = formatModelSize(progress.completed);
|
||||
const total = formatModelSize(progress.total);
|
||||
ctx.ui.setWidget("ollama-pull", [
|
||||
`Pulling ${modelName}... ${percent}% (${completed} / ${total})`,
|
||||
]);
|
||||
}
|
||||
} else if (progress.status) {
|
||||
ctx.ui.setWidget("ollama-pull", [`${modelName}: ${progress.status}`]);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.ui.setWidget("ollama-pull", undefined);
|
||||
ctx.ui.notify(`${modelName} pulled successfully`, "success");
|
||||
} catch (err) {
|
||||
ctx.ui.setWidget("ollama-pull", undefined);
|
||||
ctx.ui.notify(
|
||||
`Failed to pull ${modelName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(modelName: string, ctx: any): Promise<void> {
|
||||
if (!modelName) {
|
||||
ctx.ui.notify("Usage: /ollama remove <model>", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
ctx.ui.notify("Ollama is not running", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Delete model",
|
||||
`Are you sure you want to delete ${modelName}?`,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await client.deleteModel(modelName);
|
||||
ctx.ui.notify(`${modelName} deleted`, "success");
|
||||
} catch (err) {
|
||||
ctx.ui.notify(
|
||||
`Failed to delete ${modelName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePs(ctx: any): Promise<void> {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
ctx.ui.notify("Ollama is not running", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ps = await client.getRunningModels();
|
||||
if (!ps.models || ps.models.length === 0) {
|
||||
ctx.ui.notify("No models currently loaded in memory", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = ["Running models:", ""];
|
||||
for (const m of ps.models) {
|
||||
const vram = m.size_vram > 0 ? formatModelSize(m.size_vram) + " VRAM" : "CPU only";
|
||||
const totalSize = formatModelSize(m.size);
|
||||
const expiresAt = new Date(m.expires_at);
|
||||
const idleMs = expiresAt.getTime() - Date.now();
|
||||
const idleMin = Math.max(0, Math.floor(idleMs / 60000));
|
||||
lines.push(` ${m.name} ${totalSize} ${vram} expires in ${idleMin}m`);
|
||||
}
|
||||
|
||||
await ctx.ui.custom(
|
||||
(tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
|
||||
const text = new Text(lines.map((l) => theme.fg("fg", l)).join("\n"), 0, 0);
|
||||
setTimeout(() => done(undefined), 0);
|
||||
return text;
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.ui.notify(
|
||||
`Failed to get running models: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
106
src/resources/extensions/ollama/ollama-discovery.ts
Normal file
106
src/resources/extensions/ollama/ollama-discovery.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// GSD2 — Ollama model discovery and capability detection
|
||||
|
||||
/**
|
||||
* Discovers locally available Ollama models and enriches them with
|
||||
* capability metadata (context window, vision, reasoning) from the
|
||||
* known model table and /api/show responses.
|
||||
*
|
||||
* Returns models in the format expected by pi.registerProvider().
|
||||
*/
|
||||
|
||||
import { listModels, getOllamaHost } from "./ollama-client.js";
|
||||
import {
|
||||
estimateContextFromParams,
|
||||
formatModelSize,
|
||||
getModelCapabilities,
|
||||
humanizeModelName,
|
||||
} from "./model-capabilities.js";
|
||||
import type { OllamaModelInfo } from "./types.js";
|
||||
|
||||
export interface DiscoveredOllamaModel {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ("text" | "image")[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
/** Raw size in bytes for display purposes */
|
||||
sizeBytes: number;
|
||||
/** Parameter size string from Ollama (e.g. "7B") */
|
||||
parameterSize: string;
|
||||
}
|
||||
|
||||
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
|
||||
function enrichModel(info: OllamaModelInfo): DiscoveredOllamaModel {
|
||||
const caps = getModelCapabilities(info.name);
|
||||
const parameterSize = info.details?.parameter_size ?? "";
|
||||
|
||||
// Determine context window: known table > estimate from param size > default
|
||||
const contextWindow =
|
||||
caps.contextWindow ??
|
||||
(parameterSize ? estimateContextFromParams(parameterSize) : 8192);
|
||||
|
||||
// Determine max tokens: known table > fraction of context > default
|
||||
const maxTokens =
|
||||
caps.maxTokens ?? Math.min(Math.floor(contextWindow / 4), 16384);
|
||||
|
||||
// Detect vision from families or known table
|
||||
const hasVision =
|
||||
caps.input?.includes("image") ??
|
||||
(info.details?.families?.some((f) => f === "clip" || f === "mllama") ?? false);
|
||||
|
||||
// Detect reasoning from known table
|
||||
const reasoning = caps.reasoning ?? false;
|
||||
|
||||
return {
|
||||
id: info.name,
|
||||
name: humanizeModelName(info.name),
|
||||
reasoning,
|
||||
input: hasVision ? ["text", "image"] : ["text"],
|
||||
cost: ZERO_COST,
|
||||
contextWindow,
|
||||
maxTokens,
|
||||
sizeBytes: info.size,
|
||||
parameterSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all locally available Ollama models with enriched capabilities.
|
||||
*/
|
||||
export async function discoverModels(): Promise<DiscoveredOllamaModel[]> {
|
||||
const tags = await listModels();
|
||||
if (!tags.models || tags.models.length === 0) return [];
|
||||
|
||||
return tags.models.map(enrichModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a discovered model for display in model list.
|
||||
*/
|
||||
export function formatModelForDisplay(model: DiscoveredOllamaModel): string {
|
||||
const parts = [model.id];
|
||||
|
||||
if (model.sizeBytes > 0) {
|
||||
parts.push(`(${formatModelSize(model.sizeBytes)})`);
|
||||
}
|
||||
|
||||
const flags: string[] = [];
|
||||
if (model.reasoning) flags.push("reasoning");
|
||||
if (model.input.includes("image")) flags.push("vision");
|
||||
|
||||
if (flags.length > 0) {
|
||||
parts.push(`[${flags.join(", ")}]`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OpenAI-compat base URL for Ollama.
|
||||
*/
|
||||
export function getOllamaOpenAIBaseUrl(): string {
|
||||
return `${getOllamaHost()}/v1`;
|
||||
}
|
||||
218
src/resources/extensions/ollama/ollama-tool.ts
Normal file
218
src/resources/extensions/ollama/ollama-tool.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// GSD2 — LLM-callable Ollama management tool
|
||||
/**
|
||||
* Registers an ollama_manage tool that the LLM can call to interact
|
||||
* with the local Ollama instance — list models, pull new ones, check status.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
||||
import { Text } from "@gsd/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import * as client from "./ollama-client.js";
|
||||
import { discoverModels, formatModelForDisplay } from "./ollama-discovery.js";
|
||||
import { formatModelSize } from "./model-capabilities.js";
|
||||
|
||||
interface OllamaToolDetails {
|
||||
action: string;
|
||||
model?: string;
|
||||
modelCount?: number;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function registerOllamaTool(pi: ExtensionAPI): void {
|
||||
pi.registerTool({
|
||||
name: "ollama_manage",
|
||||
label: "Ollama",
|
||||
description:
|
||||
"Manage local Ollama models. List available models, pull new ones, " +
|
||||
"check Ollama status, or see running models and resource usage. " +
|
||||
"Use this when you need a specific local model that isn't available yet.",
|
||||
promptSnippet: "Manage local Ollama models (list, pull, status, ps)",
|
||||
promptGuidelines: [
|
||||
"Use 'list' to see what models are available locally before trying to use one.",
|
||||
"Use 'pull' to download a model that isn't available yet.",
|
||||
"Use 'status' to check if Ollama is running.",
|
||||
"Use 'ps' to see which models are loaded in memory and VRAM usage.",
|
||||
"Common models: llama3.1:8b, qwen2.5-coder:7b, deepseek-r1:8b, codestral:22b",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: Type.Union(
|
||||
[
|
||||
Type.Literal("list"),
|
||||
Type.Literal("pull"),
|
||||
Type.Literal("status"),
|
||||
Type.Literal("ps"),
|
||||
],
|
||||
{ description: "Action to perform" },
|
||||
),
|
||||
model: Type.Optional(
|
||||
Type.String({ description: "Model name (required for pull)" }),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
||||
const startTime = Date.now();
|
||||
const { action, model } = params;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "status": {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Ollama is not running. It needs to be started with 'ollama serve'." }],
|
||||
details: { action, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
const version = await client.getVersion();
|
||||
return {
|
||||
content: [{ type: "text", text: `Ollama${version ? ` v${version}` : ""} is running at ${client.getOllamaHost()}` }],
|
||||
details: { action, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Ollama is not running." }],
|
||||
isError: true,
|
||||
details: { action, durationMs: Date.now() - startTime, error: "not_running" } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const models = await discoverModels();
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No models available. Pull one with action='pull'." }],
|
||||
details: { action, modelCount: 0, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = models.map((m) => formatModelForDisplay(m));
|
||||
return {
|
||||
content: [{ type: "text", text: `Available models:\n${lines.join("\n")}` }],
|
||||
details: { action, modelCount: models.length, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
case "pull": {
|
||||
if (!model) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: 'model' parameter is required for pull action." }],
|
||||
isError: true,
|
||||
details: { action, durationMs: Date.now() - startTime, error: "missing_model" } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Ollama is not running." }],
|
||||
isError: true,
|
||||
details: { action, model, durationMs: Date.now() - startTime, error: "not_running" } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
let lastStatus = "";
|
||||
await client.pullModel(model, (progress) => {
|
||||
if (progress.total && progress.completed) {
|
||||
const pct = Math.floor((progress.completed / progress.total) * 100);
|
||||
const status = `Pulling ${model}... ${pct}%`;
|
||||
if (status !== lastStatus) {
|
||||
lastStatus = status;
|
||||
onUpdate?.({ content: [{ type: "text", text: status }], details: { action, model, durationMs: Date.now() - startTime } as OllamaToolDetails });
|
||||
}
|
||||
} else if (progress.status && progress.status !== lastStatus) {
|
||||
lastStatus = progress.status;
|
||||
onUpdate?.({ content: [{ type: "text", text: `${model}: ${progress.status}` }], details: { action, model, durationMs: Date.now() - startTime } as OllamaToolDetails });
|
||||
}
|
||||
}, signal);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully pulled ${model}` }],
|
||||
details: { action, model, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
case "ps": {
|
||||
const running = await client.isRunning();
|
||||
if (!running) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Ollama is not running." }],
|
||||
isError: true,
|
||||
details: { action, durationMs: Date.now() - startTime, error: "not_running" } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const ps = await client.getRunningModels();
|
||||
if (!ps.models || ps.models.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No models currently loaded in memory." }],
|
||||
details: { action, modelCount: 0, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = ps.models.map((m) => {
|
||||
const vram = m.size_vram > 0 ? `${formatModelSize(m.size_vram)} VRAM` : "CPU";
|
||||
return `${m.name} — ${formatModelSize(m.size)} total, ${vram}`;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Loaded models:\n${lines.join("\n")}` }],
|
||||
details: { action, modelCount: ps.models.length, durationMs: Date.now() - startTime } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown action: ${action}` }],
|
||||
isError: true,
|
||||
details: { action, durationMs: Date.now() - startTime, error: "unknown_action" } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Ollama error: ${msg}` }],
|
||||
isError: true,
|
||||
details: { action, model, durationMs: Date.now() - startTime, error: msg } as OllamaToolDetails,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("ollama "));
|
||||
text += theme.fg("accent", args.action);
|
||||
if (args.model) {
|
||||
text += theme.fg("dim", ` ${args.model}`);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { isPartial, expanded }, theme) {
|
||||
const d = result.details as OllamaToolDetails | undefined;
|
||||
|
||||
if (isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
|
||||
if ((result as any).isError || d?.error) {
|
||||
return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0);
|
||||
}
|
||||
|
||||
let text = theme.fg("success", d?.action ?? "done");
|
||||
if (d?.modelCount !== undefined) {
|
||||
text += theme.fg("dim", ` (${d.modelCount} models)`);
|
||||
}
|
||||
text += theme.fg("dim", ` ${d?.durationMs ?? 0}ms`);
|
||||
|
||||
if (expanded) {
|
||||
const content = result.content[0];
|
||||
if (content?.type === "text") {
|
||||
const preview = content.text.split("\n").slice(0, 10).join("\n");
|
||||
text += "\n\n" + theme.fg("dim", preview);
|
||||
}
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
162
src/resources/extensions/ollama/tests/model-capabilities.test.ts
Normal file
162
src/resources/extensions/ollama/tests/model-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// GSD2 — Tests for Ollama model capability detection
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getModelCapabilities,
|
||||
estimateContextFromParams,
|
||||
humanizeModelName,
|
||||
formatModelSize,
|
||||
} from "../model-capabilities.js";
|
||||
|
||||
// ─── getModelCapabilities ────────────────────────────────────────────────────
|
||||
|
||||
describe("getModelCapabilities", () => {
|
||||
it("returns reasoning for deepseek-r1 models", () => {
|
||||
const caps = getModelCapabilities("deepseek-r1:8b");
|
||||
assert.equal(caps.reasoning, true);
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
});
|
||||
|
||||
it("returns reasoning for qwq models", () => {
|
||||
const caps = getModelCapabilities("qwq:32b");
|
||||
assert.equal(caps.reasoning, true);
|
||||
});
|
||||
|
||||
it("returns vision for llava models", () => {
|
||||
const caps = getModelCapabilities("llava:7b");
|
||||
assert.deepEqual(caps.input, ["text", "image"]);
|
||||
});
|
||||
|
||||
it("returns vision for llama3.2-vision models", () => {
|
||||
const caps = getModelCapabilities("llama3.2-vision:11b");
|
||||
assert.deepEqual(caps.input, ["text", "image"]);
|
||||
});
|
||||
|
||||
it("returns correct context for llama3.1", () => {
|
||||
const caps = getModelCapabilities("llama3.1:8b");
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
});
|
||||
|
||||
it("returns correct context for llama3 (no .1)", () => {
|
||||
const caps = getModelCapabilities("llama3:8b");
|
||||
assert.equal(caps.contextWindow, 8192);
|
||||
});
|
||||
|
||||
it("returns correct context for llama2", () => {
|
||||
const caps = getModelCapabilities("llama2:7b");
|
||||
assert.equal(caps.contextWindow, 4096);
|
||||
});
|
||||
|
||||
it("returns correct context for qwen2.5-coder", () => {
|
||||
const caps = getModelCapabilities("qwen2.5-coder:7b");
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
assert.equal(caps.maxTokens, 32768);
|
||||
});
|
||||
|
||||
it("returns correct context for codestral", () => {
|
||||
const caps = getModelCapabilities("codestral:22b");
|
||||
assert.equal(caps.contextWindow, 262144);
|
||||
});
|
||||
|
||||
it("returns correct context for mistral-nemo", () => {
|
||||
const caps = getModelCapabilities("mistral-nemo:12b");
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
});
|
||||
|
||||
it("returns correct context for gemma3", () => {
|
||||
const caps = getModelCapabilities("gemma3:9b");
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
});
|
||||
|
||||
it("returns empty object for unknown models", () => {
|
||||
const caps = getModelCapabilities("totally-unknown-model:3b");
|
||||
assert.deepEqual(caps, {});
|
||||
});
|
||||
|
||||
it("strips tag before matching", () => {
|
||||
const caps = getModelCapabilities("llama3.1:70b-instruct-q4_0");
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
const caps = getModelCapabilities("Llama3.1:8B");
|
||||
assert.equal(caps.contextWindow, 131072);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── estimateContextFromParams ───────────────────────────────────────────────
|
||||
|
||||
describe("estimateContextFromParams", () => {
|
||||
it("estimates 8192 for small models", () => {
|
||||
assert.equal(estimateContextFromParams("1.5B"), 8192);
|
||||
});
|
||||
|
||||
it("estimates 16384 for 7B models", () => {
|
||||
assert.equal(estimateContextFromParams("7B"), 16384);
|
||||
});
|
||||
|
||||
it("estimates 32768 for 13B models", () => {
|
||||
assert.equal(estimateContextFromParams("13B"), 32768);
|
||||
});
|
||||
|
||||
it("estimates 65536 for 34B models", () => {
|
||||
assert.equal(estimateContextFromParams("34B"), 65536);
|
||||
});
|
||||
|
||||
it("estimates 131072 for 70B+ models", () => {
|
||||
assert.equal(estimateContextFromParams("70B"), 131072);
|
||||
});
|
||||
|
||||
it("handles decimal sizes", () => {
|
||||
assert.equal(estimateContextFromParams("7.5B"), 16384);
|
||||
});
|
||||
|
||||
it("handles M (millions)", () => {
|
||||
assert.equal(estimateContextFromParams("500M"), 8192);
|
||||
});
|
||||
|
||||
it("returns 8192 for unparseable input", () => {
|
||||
assert.equal(estimateContextFromParams("unknown"), 8192);
|
||||
});
|
||||
|
||||
it("returns 8192 for empty string", () => {
|
||||
assert.equal(estimateContextFromParams(""), 8192);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── humanizeModelName ───────────────────────────────────────────────────────
|
||||
|
||||
describe("humanizeModelName", () => {
|
||||
it("capitalizes and adds tag", () => {
|
||||
assert.equal(humanizeModelName("llama3.1:8b"), "Llama 3.1 8B");
|
||||
});
|
||||
|
||||
it("handles latest tag", () => {
|
||||
assert.equal(humanizeModelName("llama3.1:latest"), "Llama 3.1");
|
||||
});
|
||||
|
||||
it("handles no tag", () => {
|
||||
assert.equal(humanizeModelName("llama3.1"), "Llama 3.1");
|
||||
});
|
||||
|
||||
it("handles hyphenated names", () => {
|
||||
const result = humanizeModelName("deepseek-r1:8b");
|
||||
assert.ok(result.includes("8B"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatModelSize ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("formatModelSize", () => {
|
||||
it("formats GB", () => {
|
||||
assert.equal(formatModelSize(4_700_000_000), "4.7 GB");
|
||||
});
|
||||
|
||||
it("formats MB", () => {
|
||||
assert.equal(formatModelSize(500_000_000), "500.0 MB");
|
||||
});
|
||||
|
||||
it("formats KB", () => {
|
||||
assert.equal(formatModelSize(500_000), "500 KB");
|
||||
});
|
||||
});
|
||||
38
src/resources/extensions/ollama/tests/ollama-client.test.ts
Normal file
38
src/resources/extensions/ollama/tests/ollama-client.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// GSD2 — Tests for Ollama HTTP client
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getOllamaHost } from "../ollama-client.js";
|
||||
|
||||
// ─── getOllamaHost ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("getOllamaHost", () => {
|
||||
const originalHost = process.env.OLLAMA_HOST;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHost === undefined) {
|
||||
delete process.env.OLLAMA_HOST;
|
||||
} else {
|
||||
process.env.OLLAMA_HOST = originalHost;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns default when OLLAMA_HOST is not set", () => {
|
||||
delete process.env.OLLAMA_HOST;
|
||||
assert.equal(getOllamaHost(), "http://localhost:11434");
|
||||
});
|
||||
|
||||
it("returns OLLAMA_HOST when set with scheme", () => {
|
||||
process.env.OLLAMA_HOST = "http://myhost:12345";
|
||||
assert.equal(getOllamaHost(), "http://myhost:12345");
|
||||
});
|
||||
|
||||
it("adds http:// when OLLAMA_HOST has no scheme", () => {
|
||||
process.env.OLLAMA_HOST = "myhost:12345";
|
||||
assert.equal(getOllamaHost(), "http://myhost:12345");
|
||||
});
|
||||
|
||||
it("preserves https:// scheme", () => {
|
||||
process.env.OLLAMA_HOST = "https://secure-ollama.example.com";
|
||||
assert.equal(getOllamaHost(), "https://secure-ollama.example.com");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// GSD2 — Tests for Ollama model discovery and enrichment
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getOllamaOpenAIBaseUrl } from "../ollama-discovery.js";
|
||||
|
||||
// ─── getOllamaOpenAIBaseUrl ─────────────────────────────────────────────────
|
||||
|
||||
describe("getOllamaOpenAIBaseUrl", () => {
|
||||
const originalHost = process.env.OLLAMA_HOST;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHost === undefined) {
|
||||
delete process.env.OLLAMA_HOST;
|
||||
} else {
|
||||
process.env.OLLAMA_HOST = originalHost;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns default OpenAI-compat URL", () => {
|
||||
delete process.env.OLLAMA_HOST;
|
||||
assert.equal(getOllamaOpenAIBaseUrl(), "http://localhost:11434/v1");
|
||||
});
|
||||
|
||||
it("appends /v1 to custom OLLAMA_HOST", () => {
|
||||
process.env.OLLAMA_HOST = "http://remote:9999";
|
||||
assert.equal(getOllamaOpenAIBaseUrl(), "http://remote:9999/v1");
|
||||
});
|
||||
});
|
||||
130
src/resources/extensions/ollama/types.ts
Normal file
130
src/resources/extensions/ollama/types.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// GSD2 — Ollama API response types
|
||||
|
||||
/**
|
||||
* Type definitions for the Ollama REST API.
|
||||
* Reference: https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
*/
|
||||
|
||||
// ─── /api/tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OllamaModelDetails {
|
||||
parent_model: string;
|
||||
format: string;
|
||||
family: string;
|
||||
families: string[] | null;
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
}
|
||||
|
||||
export interface OllamaModelInfo {
|
||||
name: string;
|
||||
model: string;
|
||||
modified_at: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
details: OllamaModelDetails;
|
||||
}
|
||||
|
||||
export interface OllamaTagsResponse {
|
||||
models: OllamaModelInfo[];
|
||||
}
|
||||
|
||||
// ─── /api/show ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OllamaShowResponse {
|
||||
modelfile: string;
|
||||
parameters: string;
|
||||
template: string;
|
||||
details: OllamaModelDetails;
|
||||
model_info: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── /api/ps ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OllamaRunningModel {
|
||||
name: string;
|
||||
model: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
details: OllamaModelDetails;
|
||||
expires_at: string;
|
||||
size_vram: number;
|
||||
}
|
||||
|
||||
export interface OllamaPsResponse {
|
||||
models: OllamaRunningModel[];
|
||||
}
|
||||
|
||||
// ─── /api/pull ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OllamaPullProgress {
|
||||
status: string;
|
||||
digest?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
}
|
||||
|
||||
// ─── /api/version ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface OllamaVersionResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
// ─── /api/chat ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OllamaChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
images?: string[];
|
||||
tool_calls?: OllamaToolCall[];
|
||||
}
|
||||
|
||||
export interface OllamaToolCall {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
required?: string[];
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaChatRequest {
|
||||
model: string;
|
||||
messages: OllamaChatMessage[];
|
||||
stream?: boolean;
|
||||
tools?: OllamaTool[];
|
||||
options?: {
|
||||
num_ctx?: number;
|
||||
num_predict?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
stop?: string[];
|
||||
};
|
||||
keep_alive?: string;
|
||||
}
|
||||
|
||||
export interface OllamaChatResponse {
|
||||
model: string;
|
||||
created_at: string;
|
||||
message: OllamaChatMessage;
|
||||
done: boolean;
|
||||
done_reason?: string;
|
||||
total_duration?: number;
|
||||
load_duration?: number;
|
||||
prompt_eval_count?: number;
|
||||
prompt_eval_duration?: number;
|
||||
eval_count?: number;
|
||||
eval_duration?: number;
|
||||
}
|
||||
|
|
@ -1,24 +1,45 @@
|
|||
# Changelog
|
||||
|
||||
## [0.3.0]
|
||||
|
||||
### Added
|
||||
|
||||
- **SCM provider** — "GSD Agent" appears in Source Control panel with accept/discard per-file diffs
|
||||
- **Change tracker** — captures original file content before agent modifications for diff and rollback
|
||||
- **Checkpoints** — automatic snapshots on each agent turn with restore capability
|
||||
- **Diagnostic bridge** — "Fix Problems in File" and "Fix All Problems" commands read VS Code diagnostics and send to agent
|
||||
- **Line-level decorations** — green/yellow highlights on agent-modified lines with gutter indicators
|
||||
- **Chat context injection** — auto-includes editor selection and file diagnostics when relevant
|
||||
- **Git integration** — commit agent changes, create branches, show diffs
|
||||
- **Approval modes** — auto-approve, ask (prompts before writes), plan-only (read-only)
|
||||
- **UI request handling** — agent questions, confirmations, and selections now show as VS Code dialogs instead of hanging
|
||||
- **Fix Errors button** — quick access to diagnostic fixing in sidebar Actions
|
||||
- **5 new settings** — `showProgressNotifications`, `activityFeedMaxItems`, `showContextWarning`, `contextWarningThreshold`, `approvalMode`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sidebar redesign** — compact card-based layout with collapsible sections, pill toggles, hidden empty data
|
||||
- **Workflow buttons** now route through Chat panel so responses are visible
|
||||
- **Slash completion** filtered to `/gsd` commands only
|
||||
- **Checkpoint labels** show timestamp + first action (e.g., "10:32 — Edit sidebar.ts")
|
||||
- **Session tree** supports ISO timestamp filenames (GSD's actual format)
|
||||
- **Session persistence** enabled (removed `--no-session` flag)
|
||||
- **Progress notifications** disabled by default (Chat panel provides inline progress)
|
||||
- **Sidebar reduced** from 6 panels to 3 (GSD Agent, Sessions, Activity)
|
||||
- **Settings section** starts collapsed by default
|
||||
|
||||
## [0.2.0]
|
||||
|
||||
### Added
|
||||
|
||||
- **Activity feed** — real-time TreeView showing tool executions (Read, Write, Edit, Bash, Grep, Glob) with status icons, duration, and click-to-open
|
||||
- **Workflow controls** — sidebar buttons for Auto, Next, Quick Task, Capture, Status, and Fork that send `/gsd` slash commands
|
||||
- **Progress notifications** — VS Code notification with cancel button while the agent is working
|
||||
- **Context window indicator** — color-coded usage bar (green/yellow/red) in sidebar with configurable threshold warnings
|
||||
- **Session forking** — fork from any message via QuickPick using `get_fork_messages` and `fork` RPC commands
|
||||
- **Queue mode controls** — toggle steering and follow-up modes (all vs one-at-a-time) from the sidebar
|
||||
- **Enhanced conversation history** — tool call rendering, collapsible thinking blocks, search/filter, fork-from-here buttons
|
||||
- **Enhanced code lens** — Refactor, Find Bugs, and Generate Tests actions alongside Ask GSD
|
||||
- **4 new settings** — `showProgressNotifications`, `activityFeedMaxItems`, `showContextWarning`, `contextWarningThreshold`
|
||||
- **8 new commands** (33 total) — `clearActivity`, `forkSession`, `toggleSteeringMode`, `toggleFollowUpMode`, `refactorSymbol`, `findBugsSymbol`, `generateTestsSymbol`
|
||||
|
||||
### Changed
|
||||
|
||||
- Sidebar session table now shows steering and follow-up queue mode with clickable toggle badges
|
||||
- Token usage section includes context window usage bar when model context window is known
|
||||
- **Activity feed** — real-time TreeView showing tool executions with status icons, duration, and click-to-open
|
||||
- **Workflow controls** — sidebar buttons for Auto, Next, Quick Task, Capture
|
||||
- **Context window indicator** — color-coded usage bar in sidebar with threshold warnings
|
||||
- **Session forking** — fork from any message via QuickPick
|
||||
- **Queue mode controls** — toggle steering and follow-up modes from the sidebar
|
||||
- **Enhanced conversation history** — tool call rendering, collapsible thinking blocks, search/filter, fork-from-here
|
||||
- **Enhanced code lens** — Refactor, Find Bugs, and Generate Tests alongside Ask GSD
|
||||
- **8 new commands** (33 total)
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
|
|
@ -31,7 +52,7 @@ Initial release.
|
|||
- Bash terminal — pseudoterminal routing agent Bash tool output
|
||||
- Session tree — browse and switch between session files
|
||||
- Conversation history — webview panel with full chat log
|
||||
- Slash command completion — auto-complete for `/gsd` commands in editors
|
||||
- Slash command completion — auto-complete for `/gsd` commands
|
||||
- Code lens — "Ask GSD" above functions and classes in TS/JS/Python/Go/Rust
|
||||
- 25 commands with 6 keyboard shortcuts
|
||||
- Auto-start, auto-compaction, and code lens configuration
|
||||
|
|
|
|||
|
|
@ -1,88 +1,193 @@
|
|||
# GSD-2 — VS Code Extension
|
||||
|
||||
Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd` in VS Code Chat, and monitor your agent from a sidebar dashboard — all without leaving the editor.
|
||||
Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd`, monitor agent activity in real-time, review and accept/reject changes, and manage your workflow — all without leaving the editor.
|
||||
|
||||

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