diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583a2ae3f9..e9104aeb17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,6 +252,31 @@ jobs: # platform backends, and the MCP stdio protocol. No GUI input runs. run: (cd crates/tui/plugins/computer-use && npm test) + vscode-extension: + name: VS Code extension + timeout-minutes: 15 + runs-on: ubuntu-latest + defaults: + run: + working-directory: extensions/vscode + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + # The extension targets VS Code >=1.90, whose extension host is + # Node 20, and its @types/node pin is ^20. Build and test on the + # runtime the extension actually ships against. + node-version: 20 + - name: Install extension dependencies + run: npm ci + - name: Run VS Code extension suites + # extensions/vscode ships node --test suites (api, markdown, sse) that + # NO workflow ran: release.yml only reads package.json for a version + # string, so the whole client compiled and shipped without its tests or + # `tsc` ever running in CI. `npm test` compiles first (tsc -p ./), so + # this is the type-check gate for the extension too. + run: npm test + safety-gate: name: Safety gate needs: changes diff --git a/.gitignore b/.gitignore index 29c6de14cb..99d8260948 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ codewhale-inference/ !.env.example node_modules/ .vscode/ +# The extension ships its own dev-loop config (F5 Extension Development Host). +# Without this negation the bare `.vscode/` above swallows it silently. +!/extensions/vscode/.vscode/ .idea/ *.swp *.swo diff --git a/extensions/vscode/.vscode/launch.json b/extensions/vscode/.vscode/launch.json new file mode 100644 index 0000000000..3bb209e82c --- /dev/null +++ b/extensions/vscode/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "watch" + }, + { + "name": "Extension Tests", + "type": "node", + "request": "launch", + "runtimeArgs": ["--test", "out/test"], + "cwd": "${workspaceFolder}", + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "preLaunchTask": "compile" + } + ] +} diff --git a/extensions/vscode/.vscode/tasks.json b/extensions/vscode/.vscode/tasks.json new file mode 100644 index 0000000000..ede85dcf95 --- /dev/null +++ b/extensions/vscode/.vscode/tasks.json @@ -0,0 +1,26 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "watch", + "detail": "tsc -p ./ --watch — background compile for the Run Extension launch config", + "type": "shell", + "command": "npx tsc -p ./ --watch", + "options": { "cwd": "${workspaceFolder}" }, + "isBackground": true, + "problemMatcher": "$tsc-watch", + "presentation": { "reveal": "never", "panel": "dedicated" }, + "group": { "kind": "build", "isDefault": true } + }, + { + "label": "compile", + "detail": "tsc -p ./ — one-shot build into out/", + "type": "shell", + "command": "npx tsc -p ./", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": "$tsc", + "presentation": { "reveal": "silent", "panel": "dedicated" }, + "group": "build" + } + ] +} diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore new file mode 100644 index 0000000000..661befaf77 --- /dev/null +++ b/extensions/vscode/.vscodeignore @@ -0,0 +1,6 @@ +src/** +node_modules/** +out/test/** +*.vsix +tsconfig.json +package-lock.json diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 59f9169487..9b888ae2f0 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -1,36 +1,68 @@ # CodeWhale for VS Code -Official Codewhale extension scaffold for local development. +Official Codewhale extension: an agentic chat sidebar over the local Engine +Runtime API, with editor context, streaming turns, approvals, and terminal +parity. -This first slice is intentionally small: +## What it does -- open Codewhale in an integrated terminal -- start `codewhale serve --http` in a visible terminal -- check a local runtime through `/health` and `/v1/runtime/info` -- show connection state in the status bar -- show a read-only Agent View with recent runtime thread summaries from - `/v1/threads/summary` -- show recent read-only restore points from `/v1/snapshots` -- refresh the read-only Agent View automatically so branch/workspace metadata - catches up while agents are working +**Chat sidebar** (primary view): -It does not expose the full chat webview, VS Code Agent View chat/editor -integration, inline edit application, marketplace publish workflow, or -retry/undo/snapshot GUI endpoints yet. +- create, switch, and resume Codewhale threads; every thread stays available + from the terminal and the embedded browser client +- stream turns live over the runtime's replayable SSE contract + (`GET /v1/threads/{id}/events?since_seq=…`) with automatic reconnection +- attach editor context as chips before sending: current selection, + active file, or Problems-panel diagnostics +- resolve tool approvals (allow / deny / remember) and clarification + questions inline, hydrated from the thread-detail snapshot so a reload + never strands pending work +- steer a running turn or stop it +- render agent replies as a safe Markdown subset; every code block gets + Copy and Insert-at-cursor actions +- open changed files from `file_change` items when the runtime includes a path -## Local Use +**Runtime view** (secondary): connection state, recent thread summaries, +restore points, and the original terminal launch helpers. + +**Connection**: the extension attaches to `codewhale serve --http` on +`127.0.0.1:7878` by default, starts it in a visible terminal on request, and +never runs its own agent engine — the runtime is the single turn/event owner. + +## Security posture + +- Runtime bearer tokens are stored in VS Code SecretStorage via + **CodeWhale: Set Runtime Token**; the legacy `codewhale.runtimeToken` + setting still works and is migrated into secret storage on first use. +- The webview renders with a strict CSP (`default-src 'none'`), and all + model output is HTML-escaped before any Markdown transform runs; links + must be http(s). +- The chat webview script is a static string — no runtime data is + interpolated into it. + +## Local use ```bash npm install -npm run compile -npm run package -code --install-extension codewhale-vscode-0.9.11.vsix +npm test # compile + unit tests +npm run package # -> codewhale-vscode-.vsix +code --install-extension codewhale-vscode-.vsix ``` -Configure `codewhale.commandPath`, `codewhale.runtimeHost`, -`codewhale.runtimePort`, `codewhale.runtimeToken`, and -`codewhale.agentViewRefreshIntervalSeconds` from VS Code settings. -Set the refresh interval to `0` to disable automatic read-only refreshes. +Settings: `codewhale.commandPath`, `codewhale.runtimeHost`, +`codewhale.runtimePort`, `codewhale.agentViewRefreshIntervalSeconds` +(`0` disables automatic refresh). Commands: **CodeWhale: Ask Codewhale** +(`ctrl+alt+c` from the editor, also on the editor context menu), +**CodeWhale: New Chat**, **CodeWhale: Set Runtime Token**, +**CodeWhale: Start Local Runtime**. Keep the runtime on `127.0.0.1` unless you deliberately front it with trusted local networking controls. + +## Not yet built + +VS Code-native diff/merge review of agent file changes (blocked on the +runtime publishing a Files/Changes contract), provider/model switching from +the composer, retry/undo/restore buttons, and account sign-in surface. The +runtime's embedded browser client (`codewhale web`) remains the full-feature +fallback for those flows. diff --git a/extensions/vscode/media/codewhale.svg b/extensions/vscode/media/codewhale.svg index f68574c58a..a082e2bc5b 100644 --- a/extensions/vscode/media/codewhale.svg +++ b/extensions/vscode/media/codewhale.svg @@ -1,9 +1,11 @@ - - - + + + diff --git a/extensions/vscode/media/icon.png b/extensions/vscode/media/icon.png index 83f2ae0e65..b05491faa9 100644 Binary files a/extensions/vscode/media/icon.png and b/extensions/vscode/media/icon.png differ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 711a869b16..f2f76a6e3a 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -1,40 +1,66 @@ { "name": "codewhale-vscode", - "displayName": "CodeWhale", - "description": "Official CodeWhale VS Code integration scaffold for local runtime attach and terminal launch.", + "displayName": "Codewhale", + "description": "Official Codewhale VS Code extension: agentic chat in the secondary sidebar over the local Engine Runtime API, with editor context, streaming turns, approvals, and terminal parity.", "version": "0.9.12", "publisher": "codewhale", "license": "MIT", + "preview": true, "icon": "media/icon.png", + "homepage": "https://github.com/Hmbown/CodeWhale#readme", "repository": { "type": "git", "url": "https://github.com/Hmbown/CodeWhale.git", "directory": "extensions/vscode" }, + "bugs": { + "url": "https://github.com/Hmbown/CodeWhale/issues" + }, + "qna": "https://github.com/Hmbown/CodeWhale/issues", "engines": { - "vscode": "^1.90.0" + "vscode": "^1.96.2" }, "categories": [ + "AI", + "Chat", "Other" ], + "keywords": [ + "codewhale", + "agent", + "ai", + "chat", + "coding" + ], "activationEvents": [ + "onStartupFinished", + "onCommand:codewhale.ask", + "onCommand:codewhale.newChat", "onCommand:codewhale.openTerminal", "onCommand:codewhale.startRuntime", "onCommand:codewhale.checkRuntime", + "onCommand:codewhale.setRuntimeToken", "onCommand:codewhale.refreshAgentView", "onCommand:codewhale.refreshSnapshots", "onCommand:codewhale.openRuntimeDocs", - "onView:codewhale.runtimeStatus" + "onView:codewhale.chat", + "onView:codewhale.chatSecondary" ], "main": "./out/extension.js", - "files": [ - "out", - "media", - "README.md", - "LICENSE" - ], "contributes": { "commands": [ + { + "command": "codewhale.ask", + "title": "CodeWhale: Ask Codewhale" + }, + { + "command": "codewhale.newChat", + "title": "CodeWhale: New Chat" + }, + { + "command": "codewhale.setRuntimeToken", + "title": "CodeWhale: Set Runtime Token" + }, { "command": "codewhale.openTerminal", "title": "CodeWhale: Open Terminal" @@ -60,37 +86,60 @@ "title": "CodeWhale: Open Runtime API Docs" } ], + "keybindings": [ + { + "command": "codewhale.ask", + "key": "ctrl+alt+c", + "mac": "ctrl+alt+c", + "when": "editorTextFocus" + } + ], + "menus": { + "editor/context": [ + { + "command": "codewhale.ask", + "when": "editorTextFocus", + "group": "codewhale" + } + ] + }, "configuration": { - "title": "CodeWhale", + "title": "Codewhale", "properties": { "codewhale.commandPath": { "type": "string", "default": "codewhale", - "description": "Command or absolute path used to launch CodeWhale." + "scope": "machine", + "description": "Command or absolute path used to launch Codewhale. Machine-scoped: a workspace cannot point the client at another executable." }, "codewhale.runtimeHost": { "type": "string", "default": "127.0.0.1", - "description": "Local host used for CodeWhale runtime attach checks." + "scope": "machine", + "description": "Local host used for Codewhale runtime attach checks. Machine-scoped: a workspace cannot retarget the client at another host." }, "codewhale.runtimePort": { "type": "number", "default": 7878, "minimum": 1, "maximum": 65535, - "description": "Local port used for CodeWhale runtime attach checks." + "scope": "machine", + "description": "Local port used for Codewhale runtime attach checks. Machine-scoped: a workspace cannot retarget the client at another port." }, "codewhale.runtimeToken": { "type": "string", "default": "", - "description": "Optional bearer token for authenticated runtime endpoints." + "scope": "machine", + "description": "Optional bearer token for authenticated runtime endpoints. Prefer CodeWhale: Set Runtime Token, which stores it in VS Code secret storage.", + "deprecationMessage": "Tokens set here live in plaintext settings. Use CodeWhale: Set Runtime Token instead; values entered there take precedence.", + "markdownDeprecationMessage": "Tokens set here live in plaintext settings. Use **CodeWhale: Set Runtime Token** instead; a token in secret storage takes precedence." }, "codewhale.agentViewRefreshIntervalSeconds": { "type": "number", "default": 15, "minimum": 0, "maximum": 300, - "description": "Seconds between read-only Agent View refreshes. Set to 0 to disable automatic refresh." + "description": "Seconds between read-only runtime refreshes. Set to 0 to disable automatic refresh." } } }, @@ -98,29 +147,61 @@ "activitybar": [ { "id": "codewhale", - "title": "CodeWhale", - "icon": "media/codewhale.svg" + "title": "Codewhale", + "icon": "media/codewhale.svg", + "when": "codewhale.noSecondarySidebar" + } + ], + "secondarySidebar": [ + { + "id": "codewhaleSecondary", + "title": "Codewhale", + "icon": "media/codewhale.svg", + "when": "!codewhale.noSecondarySidebar" } ] }, "views": { "codewhale": [ + { + "type": "webview", + "id": "codewhale.chat", + "name": "Codewhale", + "when": "codewhale.noSecondarySidebar" + }, + { + "type": "webview", + "id": "codewhale.runtimeStatus", + "name": "Runtime", + "when": "codewhale.noSecondarySidebar" + } + ], + "codewhaleSecondary": [ + { + "type": "webview", + "id": "codewhale.chatSecondary", + "name": "Codewhale", + "when": "!codewhale.noSecondarySidebar" + }, { "type": "webview", "id": "codewhale.runtimeStatus", - "name": "Agent View" + "name": "Runtime", + "when": "!codewhale.noSecondarySidebar" } ] } }, "scripts": { + "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "check": "npm run compile", + "test": "npm run compile && node --test out/test/*.test.js", "package": "vsce package --no-dependencies" }, "devDependencies": { "@types/node": "^20.19.27", - "@types/vscode": "^1.90.0", + "@types/vscode": "^1.106.0", "@vscode/vsce": "^3.7.0", "typescript": "^5.9.3" } diff --git a/extensions/vscode/src/api.ts b/extensions/vscode/src/api.ts new file mode 100644 index 0000000000..cb27fe60e7 --- /dev/null +++ b/extensions/vscode/src/api.ts @@ -0,0 +1,683 @@ +/** + * Codewhale Runtime HTTP/SSE client — the `/v1` contract documented in + * `docs/RUNTIME_API.md`. + * + * This module is deliberately VS Code-free so it can be unit-tested with + * plain node. Callers pass the base URL and token explicitly; the VS Code + * side of token resolution lives in `secrets.ts` / `runtime.ts`. + */ +import * as http from "node:http"; +import { SseParser, type RuntimeEvent } from "./sse"; + +export type { RuntimeEvent }; + +export interface ApiConfig { + baseUrl: string; + token?: string; +} + +export interface ThreadRecord { + id: string; + title?: string; + model?: string; + modelProvider?: string; + workspace?: string; + mode?: string; + latestTurnId?: string; + archived: boolean; + updatedAt: string; +} + +export interface ItemRecord { + id: string; + turnId?: string; + kind: string; + status?: string; + summary: string; + detail?: string; + metadata?: Record; + startedAt?: string; + endedAt?: string; +} + +export interface TurnRecord { + id: string; + threadId?: string; + status?: string; + effectiveModel?: string; + error?: string; +} + +export interface PendingApproval { + id: string; + turnId?: string; + toolName: string; + description: string; + intentSummary?: string; +} + +export interface UserInputOption { + label: string; + description?: string; +} + +export interface UserInputQuestion { + header?: string; + id: string; + question: string; + options: UserInputOption[]; + allowFreeText?: boolean; + multiSelect?: boolean; +} + +export interface PendingUserInput { + id: string; + turnId?: string; + questions: UserInputQuestion[]; +} + +export interface ThreadSummary { + id: string; + title: string; + preview: string; + model: string; + mode: string; + workspace?: string; + branch?: string; + head?: string; + dirty: boolean; + archived: boolean; + updatedAt: string; + latestTurnStatus?: string; +} + +export interface SnapshotEntry { + id: string; + label: string; + timestamp: number; +} + +export interface ThreadDetail { + thread: ThreadRecord; + turns: TurnRecord[]; + items: ItemRecord[]; + latestSeq: number; + pendingApprovals: PendingApproval[]; + pendingUserInputs: PendingUserInput[]; +} + +export interface StartTurnResult { + thread: ThreadRecord; + turn: TurnRecord; +} + +export interface ConnectionInfo { + kind: "connected" | "offline" | "auth-required" | "error"; + detail: string; + version?: string; +} + +const HEALTH_TIMEOUT_MS = 2500; +const READ_TIMEOUT_MS = 8000; +const MUTATE_TIMEOUT_MS = 20000; + +export async function checkConnection(config: ApiConfig): Promise { + const health = await requestJson(`${config.baseUrl}/health`, config, { + timeoutMs: HEALTH_TIMEOUT_MS, + }); + if (health.statusCode === 0) { + return { kind: "offline", detail: "Runtime is not reachable." }; + } + if (health.statusCode === 401) { + return { kind: "auth-required", detail: "Runtime requires a token." }; + } + if (!isOk(health.statusCode)) { + return { kind: "error", detail: `Health check returned HTTP ${health.statusCode}.` }; + } + + const info = await requestJson(`${config.baseUrl}/v1/runtime/info`, config, { + timeoutMs: HEALTH_TIMEOUT_MS, + }); + if (info.statusCode === 401) { + return { kind: "auth-required", detail: "Runtime info requires a token." }; + } + + // `/health` and `/v1/runtime/info` are intentionally unauthenticated, so a + // token-protected runtime answers both with HTTP 200. The info body carries + // the real signal: `auth_required`. + if (readBoolean(readBody(info.body).auth_required) && !config.token) { + return { + kind: "auth-required", + detail: "Runtime requires a bearer token. Store one with CodeWhale: Set Runtime Token.", + }; + } + + const version = readString(readBody(info.body).version); + return { + kind: "connected", + detail: version ? `Connected to CodeWhale ${version}.` : "Connected to CodeWhale runtime.", + version, + }; +} + +export async function listThreadSummaries(config: ApiConfig, limit = 20): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`, + config, + { timeoutMs: READ_TIMEOUT_MS }, + ); + ensureOk(response, "Thread summaries"); + return readThreadSummaries(response.body); +} + +export async function getThreadDetail(config: ApiConfig, threadId: string): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}`, + config, + { timeoutMs: READ_TIMEOUT_MS }, + ); + ensureOk(response, "Thread detail"); + return readThreadDetail(response.body); +} + +export async function createThread( + config: ApiConfig, + body: { workspace?: string; model?: string; mode?: string } = {}, +): Promise { + const response = await requestJson(`${config.baseUrl}/v1/threads`, config, { + method: "POST", + body: JSON.stringify(body), + timeoutMs: MUTATE_TIMEOUT_MS, + }); + ensureOk(response, "Create thread"); + return readThread(response.body); +} + +export interface StartTurnBody { + prompt: string; + operationKey?: string; +} + +export async function startTurn( + config: ApiConfig, + threadId: string, + body: StartTurnBody, +): Promise { + const wire: Record = { prompt: body.prompt }; + if (body.operationKey) { + wire.operation_key = body.operationKey; + } + const response = await requestJson( + `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, + config, + { method: "POST", body: JSON.stringify(wire), timeoutMs: MUTATE_TIMEOUT_MS }, + ); + ensureOk(response, "Start turn"); + const record = readBody(response.body); + return { + thread: readThread(record.thread), + turn: readTurn(record.turn), + }; +} + +export async function steerTurn( + config: ApiConfig, + threadId: string, + turnId: string, + prompt: string, +): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/steer`, + config, + { method: "POST", body: JSON.stringify({ prompt }), timeoutMs: MUTATE_TIMEOUT_MS }, + ); + ensureOk(response, "Steer"); +} + +/** + * Outcome of an interrupt. The runtime answers 409 when the turn is not + * running — that is "nothing to stop", not a failure, so it is reported as a + * value instead of thrown. + */ +export type InterruptResult = "interrupted" | "not-running"; + +export async function interruptTurn( + config: ApiConfig, + threadId: string, + turnId: string, +): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`, + config, + { method: "POST", timeoutMs: MUTATE_TIMEOUT_MS }, + ); + if (response.statusCode === 409) { + return "not-running"; + } + ensureOk(response, "Interrupt"); + return "interrupted"; +} + +export async function resolveApproval( + config: ApiConfig, + approvalId: string, + decision: "allow" | "deny", + remember = false, +): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/approvals/${encodeURIComponent(approvalId)}`, + config, + { + method: "POST", + body: JSON.stringify({ decision, remember }), + timeoutMs: MUTATE_TIMEOUT_MS, + }, + ); + ensureOk(response, "Approval"); +} + +export async function answerUserInput( + config: ApiConfig, + threadId: string, + inputId: string, + answers: Array<{ id: string; label: string; value: string }>, +): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/user-input/${encodeURIComponent(threadId)}/${encodeURIComponent(inputId)}`, + config, + { method: "POST", body: JSON.stringify({ answers }), timeoutMs: MUTATE_TIMEOUT_MS }, + ); + ensureOk(response, "User input"); +} + +export async function listSnapshots(config: ApiConfig, limit = 8): Promise { + const response = await requestJson( + `${config.baseUrl}/v1/snapshots?limit=${encodeURIComponent(String(limit))}`, + config, + { timeoutMs: READ_TIMEOUT_MS }, + ); + ensureOk(response, "Restore points"); + return readSnapshots(response.body); +} + +export interface EventStream { + readonly threadId: string; + onEvent: (event: RuntimeEvent) => void; + onError: (error: Error) => void; + close(): void; +} + +/** + * Open the replay + live SSE stream for a thread. `sinceSeq` should be the + * last accepted per-thread sequence (0 for a fresh thread). The stream never + * reconnects on its own; callers decide the retry policy from `onError`. + */ +export function openEventStream( + config: ApiConfig, + threadId: string, + sinceSeq: number, + parser = new SseParser(), +): EventStream { + const url = `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${String(sinceSeq)}`; + const request = http.get( + url, + { + headers: { + Accept: "text/event-stream", + ...(config.token ? { Authorization: `Bearer ${config.token}` } : {}), + }, + }, + (response) => { + if (!isOk(response.statusCode ?? 0)) { + response.resume(); + stream.onError(apiError(response.statusCode ?? 0, undefined, "Event stream")); + return; + } + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + for (const event of parser.push(chunk)) { + stream.onEvent(event); + } + }); + response.on("end", () => { + stream.onError(new Error("Event stream closed.")); + }); + response.on("error", (error: Error) => { + stream.onError(error); + }); + }, + ); + request.on("error", (error: Error) => { + stream.onError(error); + }); + + const stream: EventStream = { + threadId, + onEvent: () => undefined, + onError: () => undefined, + close: () => { + request.destroy(); + }, + }; + return stream; +} + +export class ApiError extends Error { + readonly statusCode: number; + readonly detail?: string; + + constructor(message: string, statusCode: number, detail?: string) { + super(detail ? `${message} ${detail}` : message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.detail = detail; + } +} + +/** + * HTTP 409 from the runtime: the request was well-formed but the resource is + * already in a state that refuses it — most often "thread already has an + * active turn". Callers catch this to say "a turn is already running" instead + * of reporting a generic failure. + */ +export class ConflictError extends ApiError { + constructor(message: string, detail?: string) { + super(message, 409, detail); + this.name = "ConflictError"; + } +} + +interface RequestResult { + statusCode: number; + body: unknown; +} + +/** + * Success is the whole 2xx range, mirroring `response.ok` in the embedded web + * client (`crates/tui/src/runtime_web/app.mjs:873`). The runtime answers 201 + * CREATED for `POST /v1/threads/{id}/turns`, so an equality check against a + * hand-picked list of codes rejects every real send. + */ +function isOk(statusCode: number): boolean { + return statusCode >= 200 && statusCode < 300; +} + +/** Build the typed error for a non-2xx status, surfacing the runtime's own message. */ +function apiError(statusCode: number, body: unknown, label: string): ApiError { + const detail = readErrorDetail(body); + if (statusCode === 0) { + return new ApiError(`${label} could not reach the runtime.`, 0, detail); + } + if (statusCode === 401) { + return new ApiError(`${label} requires the runtime token.`, 401, detail); + } + if (statusCode === 409) { + return new ConflictError(`${label} conflicts with the runtime's current state.`, detail); + } + return new ApiError(`${label} returned HTTP ${statusCode}.`, statusCode, detail); +} + +/** Throw unless the runtime answered 2xx. Every route's status check runs through here. */ +function ensureOk(response: RequestResult, label: string): void { + if (!isOk(response.statusCode)) { + throw apiError(response.statusCode, response.body, label); + } +} + +async function requestJson( + url: string, + config: ApiConfig, + options: { method?: string; body?: string; timeoutMs: number }, +): Promise { + try { + return await new Promise((resolve, reject) => { + const request = http.request( + url, + { + method: options.method ?? "GET", + timeout: options.timeoutMs, + headers: { + Accept: "application/json", + ...(options.body ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(options.body) } : {}), + ...(config.token ? { Authorization: `Bearer ${config.token}` } : {}), + }, + }, + (response) => { + let raw = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + raw += chunk; + }); + response.on("end", () => { + resolve({ statusCode: response.statusCode ?? 0, body: parseJson(raw) }); + }); + }, + ); + if (options.body) { + request.write(options.body); + } + request.on("timeout", () => { + request.destroy(new Error("Runtime request timed out.")); + }); + request.on("error", reject); + request.end(); + }); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + return { statusCode: 0, body: { error: detail } }; + } +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function readBody(body: unknown): Record { + return body && typeof body === "object" ? (body as Record) : {}; +} + +function readErrorDetail(body: unknown): string | undefined { + const record = readBody(body); + const error = record.error; + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object") { + const message = readBody(error).message ?? readBody(error).code; + if (typeof message === "string") { + return message; + } + } + return undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readBoolean(value: unknown): boolean { + return value === true; +} + +function readThread(value: unknown): ThreadRecord { + const record = readBody(value); + return { + id: readString(record.id) ?? "", + title: readString(record.title), + model: readString(record.model), + modelProvider: readString(record.model_provider), + workspace: readString(record.workspace), + mode: readString(record.mode), + latestTurnId: readString(record.latest_turn_id), + archived: record.archived === true, + updatedAt: readString(record.updated_at) ?? "", + }; +} + +function readTurn(value: unknown): TurnRecord { + const record = readBody(value); + return { + id: readString(record.id) ?? "", + threadId: readString(record.thread_id), + status: readString(record.status), + effectiveModel: readString(record.effective_model), + error: readString(record.error), + }; +} + +function readItem(value: unknown): ItemRecord | undefined { + const record = readBody(value); + const id = readString(record.id); + if (!id) { + return undefined; + } + return { + id, + turnId: readString(record.turn_id), + kind: readString(record.kind) ?? "status", + status: readString(record.status), + summary: readString(record.summary) ?? "", + detail: readString(record.detail), + metadata: + record.metadata && typeof record.metadata === "object" + ? (record.metadata as Record) + : undefined, + startedAt: readString(record.started_at), + endedAt: readString(record.ended_at), + }; +} + +function readThreadDetail(value: unknown): ThreadDetail { + const record = readBody(value); + const thread = readThread(record.thread); + const items = Array.isArray(record.items) + ? record.items.flatMap((item) => { + const parsed = readItem(item); + return parsed ? [parsed] : []; + }) + : []; + const turns = Array.isArray(record.turns) + ? record.turns.flatMap((turn) => { + const parsed = readTurn(turn); + return parsed.id ? [parsed] : []; + }) + : []; + + const pendingApprovals = Array.isArray(record.pending_approvals) + ? record.pending_approvals.flatMap((entry) => { + const approval = readBody(entry); + const id = readString(approval.id); + if (!id) { + return []; + } + return [ + { + id, + turnId: readString(approval.turn_id), + toolName: readString(approval.tool_name) ?? "tool", + description: readString(approval.description) ?? "", + intentSummary: readString(approval.intent_summary), + }, + ]; + }) + : []; + + const pendingUserInputs = Array.isArray(record.pending_user_inputs) + ? record.pending_user_inputs.flatMap((entry) => { + const input = readBody(entry); + const id = readString(input.id); + const request = readBody(input.request); + const questions = Array.isArray(request.questions) + ? request.questions.flatMap((raw) => { + const question = readBody(raw); + const questionId = readString(question.id); + if (!questionId) { + return []; + } + return [ + { + header: readString(question.header), + id: questionId, + question: readString(question.question) ?? "", + allowFreeText: question.allow_free_text === true, + multiSelect: question.multi_select === true, + options: Array.isArray(question.options) + ? question.options.flatMap((option) => { + const recordOption = readBody(option); + const label = readString(recordOption.label); + return label ? [{ label, description: readString(recordOption.description) }] : []; + }) + : [], + }, + ]; + }) + : []; + if (!id) { + return []; + } + return [{ id, turnId: readString(input.turn_id), questions }]; + }) + : []; + + return { + thread, + turns, + items, + latestSeq: readNumber(record.latest_seq) ?? 0, + pendingApprovals, + pendingUserInputs, + }; +} + +function readThreadSummaries(value: unknown): ThreadSummary[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item) => { + const record = readBody(item); + const id = readString(record.id); + if (!id) { + return []; + } + return [ + { + id, + title: readString(record.title) ?? "New Thread", + preview: readString(record.preview) ?? "", + model: readString(record.model) ?? "unknown", + mode: readString(record.mode) ?? "agent", + workspace: readString(record.workspace), + branch: readString(record.branch), + head: readString(record.head), + dirty: record.dirty === true, + archived: record.archived === true, + updatedAt: readString(record.updated_at) ?? "", + latestTurnStatus: readString(record.latest_turn_status), + }, + ]; + }); +} + +function readSnapshots(value: unknown): SnapshotEntry[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item) => { + const record = readBody(item); + const id = readString(record.id); + const label = readString(record.label); + const timestamp = readNumber(record.timestamp); + if (!id || !label || timestamp === undefined) { + return []; + } + return [{ id, label, timestamp }]; + }); +} diff --git a/extensions/vscode/src/chat.ts b/extensions/vscode/src/chat.ts new file mode 100644 index 0000000000..554b44deb1 --- /dev/null +++ b/extensions/vscode/src/chat.ts @@ -0,0 +1,1414 @@ +import * as crypto from "node:crypto"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { + answerUserInput, + checkConnection, + createThread, + getThreadDetail, + interruptTurn, + listThreadSummaries, + openEventStream, + resolveApproval, + startTurn, + steerTurn, + ApiConfig, + ConnectionInfo, + EventStream, + ItemRecord, + PendingApproval, + PendingUserInput, + ThreadDetail, + ThreadSummary, + type RuntimeEvent, +} from "./api"; +import { SseParser } from "./sse"; +import { + assemblePrompt, + collectActiveFileContext, + collectDiagnosticsContext, + collectSelectionContext, + type ContextChip, +} from "./context"; +import { + isInsideRoot, + projectItem, + statusForEvent, + type ItemView, +} from "./transcript"; + +/** + * Sidebar chat view: one active Codewhale thread at a time, streaming over + * the runtime's replayable SSE contract, with inline approvals, clarification + * questions, steer, and interrupt. State lives here; the webview only + * renders what it is told and posts intent back. + */ + +interface SyncMessage { + type: "sync"; + connection?: ConnectionInfo; + threads: ThreadSummary[]; + activeThreadId?: string; + model?: string; + streaming: boolean; + /** Interrupt asked for, turn not ended yet: keep Stop/Steer on screen. */ + interrupting: boolean; + chips: ContextChip[]; + approvals: PendingApproval[]; + inputs: PendingUserInput[]; + items: ItemView[]; +} + +type OutboundMessage = + | SyncMessage + | { type: "delta"; itemId: string; text: string } + | { type: "focusComposer" } + /** Tells the composer whether the send was accepted; it only clears on `ok`. */ + | { type: "composerResult"; ok: boolean }; + +export class ChatView implements vscode.WebviewViewProvider { + public static readonly viewType = "codewhale.chat"; + /** The secondary-sidebar twin. The same instance serves both ids. */ + public static readonly secondaryViewType = "codewhale.chatSecondary"; + + /** + * Which of the two view ids actually resolved. Only one is ever visible — + * the manifest gates them on `codewhale.noSecondarySidebar` — so `reveal()` + * must focus the one this host chose, not a hardcoded id. + */ + private resolvedViewType: string = ChatView.viewType; + + private view?: vscode.WebviewView; + private webviewReady = false; + private queued: OutboundMessage[] = []; + + private connection?: ConnectionInfo; + private threads: ThreadSummary[] = []; + private activeThreadId?: string; + private activeDetail?: ThreadDetail; + private items = new Map(); + private itemOrder: string[] = []; + private stream?: EventStream; + private lastSeq = 0; + private streamingTurnId?: string; + private interruptRequested = false; + /** + * The in-flight send. Its `operationKey` is reused when the same prompt is + * retried after a timeout so the runtime dedupes instead of starting a + * second turn. + */ + private pendingSend?: { threadId: string; prompt: string; operationKey: string }; + private reconnectAttempt = 0; + private reconnectTimer?: ReturnType; + private chips: ContextChip[] = []; + + constructor( + private readonly extensionContext: vscode.ExtensionContext, + private readonly configProvider: () => Promise, + private readonly output: vscode.OutputChannel, + ) {} + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + // Remember which id resolved so reveal() focuses the container this host + // actually shows. viewType is readonly on WebviewView and is one of the + // two ids registered in extension.ts. + this.resolvedViewType = view.viewType || ChatView.viewType; + view.webview.options = { enableScripts: true }; + view.onDidDispose(() => { + this.closeStream(); + this.view = undefined; + this.webviewReady = false; + }); + view.webview.onDidReceiveMessage((message: { command?: string; [key: string]: unknown }) => { + void this.handleWebviewMessage(message); + }); + view.webview.html = this.renderHtml(view); + } + + /** Show the chat sidebar and put focus in the composer. */ + async reveal(): Promise { + await vscode.commands.executeCommand(`${this.resolvedViewType}.focus`); + } + + /** Connection updates pushed from the extension host. */ + setConnection(connection: ConnectionInfo): void { + this.connection = connection; + this.postSync(); + } + + async refreshThreads(): Promise { + try { + this.threads = await listThreadSummaries(await this.configProvider()); + this.postSync(); + } catch (error) { + this.logError("Thread summaries unavailable", error); + } + } + + /** "Ask Codewhale" entry: attach the current selection and focus the composer. */ + async askWithSelection(): Promise { + const chip = collectSelectionContext() ?? collectActiveFileContext(); + if (chip && !this.chips.some((existing) => existing.label === chip.label)) { + this.chips.push(chip); + } + await this.reveal(); + this.post({ type: "focusComposer" }); + } + + addChip(kind: ContextChip["kind"]): void { + const chip = + kind === "selection" + ? collectSelectionContext() + : kind === "file" + ? collectActiveFileContext() + : collectDiagnosticsContext(); + if (!chip) { + void vscode.window.showInformationMessage("Nothing to attach for that context kind."); + return; + } + this.chips = this.chips.filter((existing) => existing.label !== chip.label); + this.chips.push(chip); + this.postSync(); + } + + removeChip(id: string): void { + this.chips = this.chips.filter((chip) => chip.id !== id); + this.postSync(); + } + + async newThread(): Promise { + try { + const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const thread = await createThread(await this.configProvider(), workspace ? { workspace } : {}); + this.output.appendLine(`Created thread ${thread.id}`); + await this.selectThread(thread.id); + await this.refreshThreads(); + } catch (error) { + this.logError("Create thread failed", error); + void vscode.window.showErrorMessage(errorMessage(error)); + } + } + + async selectThread(threadId: string): Promise { + this.closeStream(); + this.streamingTurnId = undefined; + this.interruptRequested = false; + this.items.clear(); + this.itemOrder = []; + this.activeThreadId = threadId; + this.postSync(); + try { + const detail = await getThreadDetail(await this.configProvider(), threadId); + if (this.activeThreadId !== threadId) { + return; // user switched away while loading + } + this.activeDetail = detail; + this.lastSeq = detail.latestSeq; + for (const item of detail.items) { + this.ingestItem(item); + } + this.openStream(threadId, detail.latestSeq).catch((error) => this.logError("Stream failed", error)); + this.postSync(); + this.scheduleThreadListRefresh(); + } catch (error) { + this.logError("Load thread failed", error); + void vscode.window.showErrorMessage(errorMessage(error)); + } + } + + // ---- webview -> extension ---- + + private async handleWebviewMessage(message: { command?: string; [key: string]: unknown }): Promise { + switch (message.command) { + case "ready": + this.webviewReady = true; + for (const queued of this.queued.splice(0)) { + void this.view?.webview.postMessage(queued); + } + this.postSync(); + break; + case "check": + await vscode.commands.executeCommand("codewhale.checkRuntime"); + break; + case "start": + await vscode.commands.executeCommand("codewhale.startRuntime"); + break; + case "terminal": + await vscode.commands.executeCommand("codewhale.openTerminal"); + break; + case "setToken": + await vscode.commands.executeCommand("codewhale.setRuntimeToken"); + break; + case "newThread": + await this.newThread(); + break; + case "selectThread": + await this.selectThread(String(message.id ?? "")); + break; + case "refreshThreads": + await this.refreshThreads(); + break; + case "sendPrompt": + await this.sendPrompt(String(message.text ?? "")); + break; + case "steer": + await this.steer(String(message.text ?? "")); + break; + case "interrupt": + await this.interrupt(); + break; + case "decideApproval": + await this.decideApproval( + String(message.id ?? ""), + message.decision === "allow" ? "allow" : "deny", + message.remember === true, + ); + break; + case "answerInput": + await this.answerInput(message); + break; + case "addChip": + this.addChip(message.kind === "file" ? "file" : message.kind === "diagnostics" ? "diagnostics" : "selection"); + break; + case "removeChip": + this.removeChip(String(message.id ?? "")); + break; + case "copyCode": + await vscode.env.clipboard.writeText(String(message.code ?? "")); + break; + case "insertCode": + await this.insertAtCursor(String(message.code ?? "")); + break; + case "openFile": + await this.openFileAtPath(String(message.path ?? "")); + break; + case "openLink": { + const url = String(message.url ?? ""); + if (/^https?:\/\//.test(url)) { + void vscode.env.openExternal(vscode.Uri.parse(url)); + } + break; + } + } + } + + private async sendPrompt(text: string): Promise { + const prompt = text.trim(); + if (!prompt) { + this.post({ type: "composerResult", ok: false }); + return; + } + if (!this.activeThreadId) { + await this.newThread(); + if (!this.activeThreadId) { + this.post({ type: "composerResult", ok: false }); + return; + } + } + const threadId = this.activeThreadId; + const assembled = assemblePrompt(prompt, this.chips); + // A resend of the same pending prompt must carry the same operation key, + // or a timeout-then-retry lands as two turns. + const operationKey = + this.pendingSend && this.pendingSend.threadId === threadId && this.pendingSend.prompt === assembled + ? this.pendingSend.operationKey + : crypto.randomUUID(); + this.pendingSend = { threadId, prompt: assembled, operationKey }; + try { + const result = await startTurn(await this.configProvider(), threadId, { + prompt: assembled, + operationKey, + }); + // Accepted: only now is it safe to drop the composer text and chips. + this.pendingSend = undefined; + this.chips = []; + this.streamingTurnId = result.turn.id; + this.interruptRequested = false; + this.addLocalUserMessage(prompt); + this.post({ type: "composerResult", ok: true }); + void this.openStream(threadId, this.lastSeq); + this.postSync(); + } catch (error) { + // Keep `pendingSend` so the retry reuses the key, and give the text back. + this.post({ type: "composerResult", ok: false }); + this.handleError("Send failed", error); + } + } + + private async steer(text: string): Promise { + if (!this.activeThreadId || !this.streamingTurnId) { + return; + } + try { + await steerTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId, text.trim()); + this.addLocalUserMessage(`[steer] ${text.trim()}`); + } catch (error) { + this.handleError("Steer failed", error); + } + } + + private async interrupt(): Promise { + if (!this.activeThreadId || !this.streamingTurnId) { + return; + } + try { + await interruptTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId); + this.output.appendLine(`Interrupt requested for turn ${this.streamingTurnId}`); + } catch (error) { + this.handleError("Interrupt failed", error); + } + } + + private async decideApproval(id: string, decision: "allow" | "deny", remember: boolean): Promise { + try { + await resolveApproval(await this.configProvider(), id, decision, remember); + } catch (error) { + this.handleError("Approval failed", error); + } + } + + private async answerInput(message: { [key: string]: unknown }): Promise { + if (!this.activeThreadId) { + return; + } + const raw = Array.isArray(message.answers) ? message.answers : []; + const answers = raw.flatMap((entry) => { + if (!entry || typeof entry !== "object") { + return []; + } + const record = entry as Record; + const id = typeof record.id === "string" ? record.id : undefined; + const label = typeof record.label === "string" ? record.label : undefined; + if (!id || !label) { + return []; + } + return [{ id, label, value: typeof record.value === "string" ? record.value : label }]; + }); + if (answers.length === 0) { + return; + } + try { + await answerUserInput(await this.configProvider(), this.activeThreadId, String(message.inputId ?? ""), answers); + } catch (error) { + this.handleError("Answer failed", error); + } + } + + private async insertAtCursor(code: string): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + void vscode.window.showInformationMessage("Open a file to insert code."); + return; + } + await editor.edit((builder) => builder.replace(editor.selection, code)); + void vscode.window.showTextDocument(editor.document); + } + + /** + * Open a path that came from an item's tool metadata. That value is + * model-influenced, so it is treated as untrusted: workspace-relative + * resolution is preferred, `..` escapes are refused outright, and anything + * outside the workspace needs an explicit confirmation from the user. + */ + private async openFileAtPath(raw: string): Promise { + const candidate = raw.trim(); + if (!candidate || /[\u0000-\u001f\u007f]/.test(candidate)) { + return; + } + const folders = vscode.workspace.workspaceFolders ?? []; + const targets: vscode.Uri[] = []; + + if (path.isAbsolute(candidate)) { + const resolved = path.resolve(candidate); + const inWorkspace = folders.some((folder) => isInsideRoot(folder.uri.fsPath, resolved)); + if (!inWorkspace) { + const choice = await vscode.window.showWarningMessage( + `Open a file outside this workspace?\n\n${resolved}`, + { modal: true }, + "Open File", + ); + if (choice !== "Open File") { + return; + } + } + targets.push(vscode.Uri.file(resolved)); + } else { + for (const folder of folders) { + // joinPath keeps the folder's scheme (remote/virtual workspaces); the + // containment check runs on the resolved filesystem path. + if (isInsideRoot(folder.uri.fsPath, candidate)) { + targets.push(vscode.Uri.joinPath(folder.uri, candidate)); + } + } + if (targets.length === 0) { + void vscode.window.showWarningMessage(`Refused to open a path outside the workspace: ${candidate}`); + return; + } + } + + for (const uri of targets) { + try { + await vscode.workspace.fs.stat(uri); + await vscode.window.showTextDocument(uri, { preview: true }); + return; + } catch { + // try the next candidate + } + } + void vscode.window.showInformationMessage(`File not found: ${candidate}`); + } + + // ---- SSE event ingestion ---- + + private async openStream(threadId: string, sinceSeq: number): Promise { + this.closeStream(); + this.reconnectAttempt = 0; + const config = await this.configProvider(); + if (this.activeThreadId !== threadId) { + return; + } + const stream = openEventStream(config, threadId, sinceSeq, new SseParser()); + stream.onEvent = (event) => this.handleStreamEvent(event); + stream.onError = (error) => this.handleStreamError(threadId, error); + this.stream = stream; + } + + private closeStream(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + this.stream?.close(); + this.stream = undefined; + } + + private handleStreamEvent(event: RuntimeEvent): void { + if (event.seq <= this.lastSeq) { + return; // duplicate or stale replay + } + this.lastSeq = event.seq; + this.reconnectAttempt = 0; + + switch (event.event) { + case "item.started": + case "item.completed": + case "item.failed": + case "item.interrupted": + case "item.canceled": { + const payloadItem = readPayloadItem(event.payload); + const itemId = event.itemId ?? payloadItem?.id; + if (!itemId) { + return; + } + const existing = this.items.get(itemId); + const merged: ItemRecord = { + id: itemId, + turnId: event.turnId ?? existing?.turnId, + kind: payloadItem?.kind ?? existing?.kind ?? "status", + status: payloadItem?.status ?? statusForEvent(event.event), + summary: payloadItem?.summary ?? existing?.summary ?? "", + detail: payloadItem?.detail ?? existing?.detail, + metadata: payloadItem?.metadata ?? existing?.metadata, + }; + this.ingestItem(merged, event.event); + this.postSync(); + break; + } + case "item.delta": { + const delta = readPayloadDelta(event.payload); + if (!delta || !event.itemId) { + return; + } + const view = this.items.get(event.itemId); + if (view) { + view.streamText = (view.streamText ?? "") + delta; + view.rev += 1; + } else { + this.ingestItem({ + id: event.itemId, + turnId: event.turnId, + kind: readPayloadKind(event.payload) ?? "agent_message", + summary: delta, + }); + } + this.post({ type: "delta", itemId: event.itemId, text: delta }); + break; + } + case "approval.required": { + const approval = readPayloadApproval(event.payload); + if (approval && this.activeDetail) { + this.activeDetail.pendingApprovals = [ + ...this.activeDetail.pendingApprovals.filter((entry) => entry.id !== approval.id), + approval, + ]; + this.postSync(); + } + break; + } + case "approval.decided": + case "approval.timeout": { + const id = readPayloadId(event.payload) ?? event.itemId; + if (id && this.activeDetail) { + this.activeDetail.pendingApprovals = this.activeDetail.pendingApprovals.filter( + (entry) => entry.id !== id, + ); + this.postSync(); + } + break; + } + case "user_input.required": { + const input = readPayloadUserInput(event.payload); + if (input && this.activeDetail) { + this.activeDetail.pendingUserInputs = [ + ...this.activeDetail.pendingUserInputs.filter((entry) => entry.id !== input.id), + input, + ]; + this.postSync(); + } + break; + } + case "user_input.answered": + case "user_input.canceled": { + const id = readPayloadId(event.payload); + if (id && this.activeDetail) { + this.activeDetail.pendingUserInputs = this.activeDetail.pendingUserInputs.filter( + (entry) => entry.id !== id, + ); + this.postSync(); + } + break; + } + case "turn.interrupt_requested": { + // The turn is still running until it reports an end state; keep the + // Stop/Steer controls on screen instead of hiding them here. + if (event.turnId && event.turnId === this.streamingTurnId) { + this.interruptRequested = true; + this.postSync(); + } + break; + } + case "turn.completed": + case "turn.failed": + case "turn.interrupted": + case "turn.ended": { + if (event.turnId && event.turnId === this.streamingTurnId) { + this.streamingTurnId = undefined; + this.interruptRequested = false; + this.postSync(); + this.scheduleThreadListRefresh(); + } + break; + } + default: + break; + } + } + + private handleStreamError(threadId: string, error: Error): void { + if (this.activeThreadId !== threadId) { + return; + } + if (error instanceof Error && "statusCode" in error && (error as { statusCode?: number }).statusCode === 401) { + this.connection = { kind: "auth-required", detail: "Runtime token was rejected." }; + this.postSync(); + return; + } + // The stream is dead; drop it, or the `!this.stream` guard below never + // passes and the reconnect silently never happens. + this.stream?.close(); + this.stream = undefined; + const attempt = ++this.reconnectAttempt; + const delay = Math.min(1000 * attempt, 5000); + this.output.appendLine(`Event stream for ${threadId} dropped (${error.message}); retrying in ${delay}ms`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + if (this.activeThreadId === threadId && !this.stream) { + void this.openStream(threadId, this.lastSeq).catch((error) => + this.logError("Stream reconnect failed", error), + ); + } + }, delay); + } + + /** Fill the item projection from a thread-detail snapshot or SSE event. */ + private ingestItem(item: ItemRecord, event?: string): void { + const existing = this.items.get(item.id); + this.items.set(item.id, projectItem(item, existing, event)); + if (!existing) { + this.itemOrder.push(item.id); + } + } + + private addLocalUserMessage(text: string): void { + const id = `local-${crypto.randomUUID()}`; + this.items.set(id, { id, kind: "user_message", summary: text, rev: 1 }); + this.itemOrder.push(id); + this.postSync(); + } + + private scheduleThreadListRefresh(): void { + // Titles/previews settle right after a turn finishes; refresh lazily. + setTimeout(() => { + void this.refreshThreads(); + }, 1200); + } + + // ---- outbound ---- + + private post(message: OutboundMessage): void { + if (!this.view) { + return; + } + if (!this.webviewReady) { + this.queued.push(message); + return; + } + void this.view.webview.postMessage(message); + } + + private postSync(): void { + this.post({ + type: "sync", + connection: this.connection, + threads: this.threads, + activeThreadId: this.activeThreadId, + model: this.activeDetail?.thread.model ?? this.threads.find((t) => t.id === this.activeThreadId)?.model, + streaming: this.streamingTurnId !== undefined, + interrupting: this.interruptRequested, + chips: this.chips, + approvals: this.activeDetail?.pendingApprovals ?? [], + inputs: this.activeDetail?.pendingUserInputs ?? [], + items: this.itemOrder.flatMap((id) => { + const view = this.items.get(id); + return view ? [view] : []; + }), + }); + } + + private handleError(where: string, error: unknown): void { + this.logError(where, error); + if (isAuthError(error)) { + this.connection = { kind: "auth-required", detail: "Runtime token was rejected." }; + this.postSync(); + } + void vscode.window.showErrorMessage(`CodeWhale ${where.toLowerCase()}: ${errorMessage(error)}`); + } + + private logError(where: string, error: unknown): void { + this.output.appendLine(`${new Date().toISOString()} ${where}: ${errorMessage(error)}`); + } + + /** Load the thread list + latest detail for the active thread (used after reconnects). */ + async resyncAfterConnection(): Promise { + await this.refreshThreads(); + if (this.activeThreadId) { + await this.selectThread(this.activeThreadId); + } + } + + // ---- webview HTML ---- + + private renderHtml(view: vscode.WebviewView): string { + const nonce = makeNonce(); + return ` + + + + + + + + +
+ + Checking runtime… + + +
+ +
+ Threads +
+
+
+
+
+ +
+
+ + + + +
+ + + Press Enter to send, Shift plus Enter for a new line. +
+ + +`; + } +} + +function readPayloadItem(payload: unknown): Partial | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const record = payload as Record; + const source = + record.item && typeof record.item === "object" ? (record.item as Record) : record; + const summary = typeof source.summary === "string" ? source.summary : undefined; + return { + id: typeof source.id === "string" ? source.id : undefined, + kind: typeof source.kind === "string" ? source.kind : undefined, + status: typeof source.status === "string" ? source.status : undefined, + summary, + detail: typeof source.detail === "string" ? source.detail : undefined, + metadata: + source.metadata && typeof source.metadata === "object" + ? (source.metadata as Record) + : undefined, + }; +} + +function readPayloadDelta(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const delta = (payload as Record).delta; + return typeof delta === "string" ? delta : undefined; +} + +function readPayloadKind(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const kind = (payload as Record).kind; + return typeof kind === "string" ? kind : undefined; +} + +function readPayloadId(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const record = payload as Record; + for (const key of ["approval_id", "input_id", "id"]) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return undefined; +} + +function readPayloadApproval(payload: unknown): PendingApproval | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const record = payload as Record; + const id = readPayloadId(record); + if (!id) { + return undefined; + } + return { + id, + turnId: typeof record.turn_id === "string" ? record.turn_id : undefined, + toolName: typeof record.tool_name === "string" ? record.tool_name : "tool", + description: typeof record.description === "string" ? record.description : "", + intentSummary: typeof record.intent_summary === "string" ? record.intent_summary : undefined, + }; +} + +function readPayloadUserInput(payload: unknown): PendingUserInput | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const record = payload as Record; + const id = readPayloadId(record); + if (!id) { + return undefined; + } + const request = + record.request && typeof record.request === "object" + ? (record.request as Record) + : record; + const questions = Array.isArray(request.questions) + ? request.questions.flatMap((raw) => { + if (!raw || typeof raw !== "object") { + return []; + } + const question = raw as Record; + const questionId = typeof question.id === "string" ? question.id : undefined; + if (!questionId) { + return []; + } + return [ + { + id: questionId, + header: typeof question.header === "string" ? question.header : undefined, + question: typeof question.question === "string" ? question.question : "", + allowFreeText: question.allow_free_text === true, + multiSelect: question.multi_select === true, + options: Array.isArray(question.options) + ? question.options.flatMap((optionRaw) => { + if (!optionRaw || typeof optionRaw !== "object") { + return []; + } + const option = optionRaw as Record; + return typeof option.label === "string" + ? [ + { + label: option.label, + description: + typeof option.description === "string" ? option.description : undefined, + }, + ] + : []; + }) + : [], + }, + ]; + }) + : []; + return { id, turnId: typeof record.turn_id === "string" ? record.turn_id : undefined, questions }; +} + +function isAuthError(error: unknown): boolean { + return error instanceof Error && "statusCode" in error && (error as { statusCode?: number }).statusCode === 401; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function makeNonce(): string { + return crypto.randomBytes(16).toString("hex"); +} + +function chatStyles(): string { + return ` + body { display: flex; flex-direction: column; height: 100vh; margin: 0; padding: 0; + color: var(--vscode-foreground); font-family: var(--vscode-font-family); font-size: var(--vscode-font-size, 13px); } + button { font-family: inherit; font-size: 11px; cursor: pointer; color: var(--vscode-button-foreground); + background: var(--vscode-button-secondaryBackground); border: none; border-radius: 3px; padding: 3px 8px; } + button.primary { background: var(--vscode-button-background); } + button.danger { background: var(--vscode-errorForeground); color: var(--vscode-editor-background); } + button:hover { filter: brightness(1.1); } + button[aria-disabled="true"] { opacity: 0.6; cursor: default; } + /* Keyboard focus must be visible everywhere it can land. */ + :focus-visible { outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: 1px; border-radius: 3px; } + button:focus-visible, summary:focus-visible, [tabindex]:focus-visible, .thread:focus-visible, .chip button:focus-visible { + outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: 1px; } + input[type="text"]:focus-visible, input[type="checkbox"]:focus-visible, textarea:focus-visible { + outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: -1px; + border-color: var(--vscode-focusBorder, #0078d4); } + /* Some hosts still report only :focus for the textarea; keep it obvious. */ + textarea:focus, input[type="text"]:focus { border-color: var(--vscode-focusBorder, #0078d4); } + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; + clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } + input[type="text"], textarea { width: 100%; box-sizing: border-box; color: var(--vscode-input-foreground); + background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 3px; + padding: 6px 8px; font-family: inherit; font-size: inherit; resize: vertical; } + header { display: flex; align-items: center; gap: 6px; padding: 8px 10px; } + .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } + .dot.connected { background: var(--vscode-testing-iconPassed, #2ea043); } + .dot.offline { background: var(--vscode-testing-iconFailed, #f14c4c); } + .dot.auth-required { background: var(--vscode-editorWarning-foreground, #cca700); } + .dot.error { background: var(--vscode-testing-iconFailed, #f14c4c); } + .spacer { flex: 1; } + .hidden { display: none !important; } + #conn-label { color: var(--vscode-descriptionForeground); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #conn-actions { display: flex; gap: 6px; padding: 0 10px 8px; flex-wrap: wrap; } + details { border-top: 1px solid var(--vscode-panel-border, #333); } + #threads-box { padding: 0 10px; } + #threads-box summary { cursor: pointer; padding: 6px 0; color: var(--vscode-descriptionForeground); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; } + .thread { padding: 5px 6px; border-radius: 4px; cursor: pointer; overflow: hidden; } + .thread:hover { background: var(--vscode-list-hoverBackground); } + .thread.active { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } + .thread-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .thread-meta { color: var(--vscode-descriptionForeground); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + main { flex: 1; overflow-y: auto; padding: 4px 10px; } + .msg { margin: 8px 0; } + .msg .who { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--vscode-descriptionForeground); margin-bottom: 2px; } + .msg.user .who { color: var(--vscode-textLink-foreground); } + .bubble { border-radius: 6px; padding: 6px 9px; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; } + .msg.user .bubble { background: var(--vscode-input-background); border: 1px solid var(--vscode-panel-border, #333); } + .msg.agent .bubble { white-space: normal; } + .msg.agent .bubble p { margin: 0 0 8px; } + .msg.agent .bubble p:last-child { margin-bottom: 0; } + .msg.agent .bubble h3, .msg.agent .bubble h4, .msg.agent .bubble h5 { margin: 10px 0 4px; } + .msg.agent .bubble ul, .msg.agent .bubble ol { margin: 4px 0; padding-left: 20px; } + .msg.agent .bubble hr { border: none; border-top: 1px solid var(--vscode-panel-border, #333); } + .msg.agent .bubble a { color: var(--vscode-textLink-foreground); } + .streaming::after { content: "▍"; animation: blink 1s steps(2) infinite; color: var(--vscode-descriptionForeground); } + @keyframes blink { 50% { opacity: 0; } } + .tool { margin: 6px 0; border: 1px solid var(--vscode-panel-border, #333); border-radius: 5px; font-size: 12px; } + .tool summary { cursor: pointer; padding: 5px 8px; color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .tool pre { margin: 0; padding: 6px 8px; overflow-x: auto; font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; white-space: pre-wrap; } + .msg.error .bubble { color: var(--vscode-errorForeground); } + .msg.status .bubble { color: var(--vscode-descriptionForeground); font-size: 11px; } + .codeblock { margin: 8px 0; border: 1px solid var(--vscode-panel-border, #333); border-radius: 5px; overflow: hidden; } + .codeblock-bar { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px 2px 8px; + background: var(--vscode-titleBar-activeBackground, #222); } + .codeblock-lang { font-size: 10px; color: var(--vscode-descriptionForeground); text-transform: uppercase; } + .codeblock-actions button { margin-left: 4px; padding: 1px 6px; font-size: 10px; } + .codeblock pre { margin: 0; padding: 8px; overflow-x: auto; font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; } + .card { margin: 8px 0; border: 1px solid var(--vscode-editorWarning-foreground, #cca700); border-radius: 6px; padding: 8px; } + .card .title { font-weight: 700; margin-bottom: 4px; } + .card .desc { color: var(--vscode-descriptionForeground); margin-bottom: 8px; overflow-wrap: anywhere; white-space: pre-wrap; } + .card .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 6px; } + .card label { font-size: 11px; color: var(--vscode-descriptionForeground); display: flex; gap: 4px; align-items: center; } + .opt { display: block; width: 100%; text-align: left; margin: 3px 0; padding: 5px 8px; } + .opt .opt-desc { display: block; font-weight: 400; color: var(--vscode-descriptionForeground); font-size: 10px; } + #chips { display: flex; gap: 4px; flex-wrap: wrap; padding: 0 10px 4px; } + .chip { display: inline-flex; gap: 4px; align-items: center; background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); border-radius: 8px; padding: 1px 8px; font-size: 10px; } + .chip button { background: transparent; color: inherit; padding: 0 2px; font-size: 11px; } + #steer-box { display: flex; gap: 6px; padding: 4px 10px; } + #steer-box input { flex: 1; } + #composer { border-top: 1px solid var(--vscode-panel-border, #333); padding: 6px 10px 10px; } + .attach-row { display: flex; gap: 4px; margin-bottom: 4px; align-items: center; } + .attach-row button { font-size: 10px; padding: 1px 6px; } + .model { margin-left: auto; color: var(--vscode-descriptionForeground); font-size: 10px; } + main:focus-visible { outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: -2px; } + #transcript .empty { color: var(--vscode-descriptionForeground); text-align: center; margin-top: 30px; line-height: 1.6; } + `; +} + +/** + * The webview script as a string. Kept here (not in a separate file) so the + * extension stays a no-bundler build; it must never interpolate runtime data. + */ +function chatScript(): string { + return ` + const vscode = acquireVsCodeApi(); + const transcript = document.getElementById("transcript"); + const attention = document.getElementById("attention"); + const chipsRow = document.getElementById("chips"); + const threadsList = document.getElementById("threads"); + const itemEls = new Map(); // item id -> element + const codeBlocks = new Map(); // item id -> [raw code] + const streamBufs = new Map(); // item id -> streaming text element + let state = { streaming: false, activeThreadId: undefined }; + + document.getElementById("btn-new").addEventListener("click", () => vscode.postMessage({ command: "newThread" })); + document.getElementById("btn-start").addEventListener("click", () => vscode.postMessage({ command: "start" })); + document.getElementById("btn-token").addEventListener("click", () => vscode.postMessage({ command: "setToken" })); + document.getElementById("btn-terminal").addEventListener("click", () => vscode.postMessage({ command: "terminal" })); + document.getElementById("btn-chip-selection").addEventListener("click", () => vscode.postMessage({ command: "addChip", kind: "selection" })); + document.getElementById("btn-chip-file").addEventListener("click", () => vscode.postMessage({ command: "addChip", kind: "file" })); + document.getElementById("btn-chip-diagnostics").addEventListener("click", () => vscode.postMessage({ command: "addChip", kind: "diagnostics" })); + document.getElementById("btn-interrupt").addEventListener("click", (e) => { + if (e.currentTarget.getAttribute("aria-disabled") === "true") { return; } + vscode.postMessage({ command: "interrupt" }); + }); + document.getElementById("btn-steer").addEventListener("click", sendSteer); + document.getElementById("steer-input").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); sendSteer(); } }); + + const promptBox = document.getElementById("prompt"); + promptBox.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendPrompt(); + } + }); + + let sending = false; + let sentText = ""; + function sendPrompt() { + const text = promptBox.value.trim(); + if (!text || sending) { return; } + sentText = promptBox.value; + // Do NOT clear here: the turn can still be refused and the text is the + // user's. It is cleared only when the extension confirms acceptance. + sending = true; + promptBox.setAttribute("aria-busy", "true"); + vscode.postMessage({ command: "sendPrompt", text }); + } + + function composerResult(ok) { + sending = false; + promptBox.removeAttribute("aria-busy"); + // Only clear what was actually sent: the user may have kept typing. + if (ok && promptBox.value === sentText) { promptBox.value = ""; } + sentText = ""; + promptBox.focus(); + } + function sendSteer() { + const box = document.getElementById("steer-input"); + const text = box.value.trim(); + if (!text) { return; } + box.value = ""; + vscode.postMessage({ command: "steer", text }); + } + + window.addEventListener("message", (event) => { + const msg = event.data; + if (msg.type === "sync") { renderSync(msg); } + else if (msg.type === "delta") { appendDelta(msg.itemId, msg.text); } + else if (msg.type === "focusComposer") { promptBox.focus(); } + else if (msg.type === "composerResult") { composerResult(msg.ok === true); } + }); + vscode.postMessage({ command: "ready" }); + + function renderSync(msg) { + state = msg; + renderConnection(msg); + renderThreads(msg); + renderChips(msg.chips || []); + renderAttention(msg); + renderItems(msg.items || []); + const running = !!msg.streaming; + document.getElementById("steer-box").classList.toggle("hidden", !running); + const stop = document.getElementById("btn-interrupt"); + stop.classList.toggle("hidden", !running); + stop.textContent = msg.interrupting ? "Stopping…" : "Stop"; + stop.setAttribute("aria-label", msg.interrupting ? "Stopping the current turn" : "Stop the current turn"); + stop.setAttribute("aria-disabled", msg.interrupting ? "true" : "false"); + document.getElementById("model-label").textContent = msg.model ? msg.model : ""; + } + + function renderConnection(msg) { + const conn = msg.connection; + const dot = document.getElementById("conn-dot"); + const label = document.getElementById("conn-label"); + const actions = document.getElementById("conn-actions"); + if (!conn) { dot.className = "dot offline"; label.textContent = "Checking runtime…"; actions.classList.add("hidden"); return; } + dot.className = "dot " + conn.kind; + label.textContent = conn.detail; + label.title = conn.detail; + const showActions = conn.kind !== "connected"; + actions.classList.toggle("hidden", !showActions); + document.getElementById("btn-token").classList.toggle("hidden", conn.kind !== "auth-required"); + } + + function renderThreads(msg) { + const threads = msg.threads || []; + document.getElementById("threads-count").textContent = "(" + threads.length + ")"; + threadsList.textContent = ""; + for (const thread of threads) { + const el = document.createElement("div"); + el.className = "thread" + (thread.id === msg.activeThreadId ? " active" : ""); + el.setAttribute("role", "button"); + el.tabIndex = 0; + if (thread.id === msg.activeThreadId) { el.setAttribute("aria-current", "true"); } + const title = document.createElement("div"); + title.className = "thread-title"; + title.textContent = thread.title || "New Thread"; + const meta = document.createElement("div"); + meta.className = "thread-meta"; + meta.textContent = [thread.model, thread.branch, thread.latestTurnStatus].filter(Boolean).join(" · "); + el.appendChild(title); + el.appendChild(meta); + const open = () => vscode.postMessage({ command: "selectThread", id: thread.id }); + el.setAttribute("aria-label", "Thread: " + (thread.title || "New Thread")); + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } + }); + threadsList.appendChild(el); + } + } + + function renderChips(chips) { + chipsRow.textContent = ""; + for (const chip of chips) { + const el = document.createElement("span"); + el.className = "chip"; + el.setAttribute("role", "listitem"); + const text = document.createElement("span"); + text.textContent = chip.label + (chip.detail ? " (" + chip.detail + ")" : ""); + const remove = document.createElement("button"); + remove.textContent = "×"; + remove.title = "Remove"; + remove.setAttribute("aria-label", "Remove context " + chip.label); + remove.addEventListener("click", () => vscode.postMessage({ command: "removeChip", id: chip.id })); + el.appendChild(text); + el.appendChild(remove); + chipsRow.appendChild(el); + } + } + + function renderAttention(msg) { + attention.textContent = ""; + for (const approval of msg.approvals || []) { + attention.appendChild(approvalCard(approval)); + } + for (const input of msg.inputs || []) { + attention.appendChild(userInputCard(msg.activeThreadId, input)); + } + } + + function approvalCard(approval) { + const card = document.createElement("div"); + card.className = "card"; + card.setAttribute("role", "group"); + card.setAttribute("aria-label", "Approval request for " + approval.toolName); + const title = document.createElement("div"); + title.className = "title"; + title.textContent = "Approval: " + approval.toolName; + const desc = document.createElement("div"); + desc.className = "desc"; + desc.textContent = approval.intentSummary ? approval.intentSummary + "\\n" + approval.description : approval.description; + const row = document.createElement("div"); + row.className = "row"; + const remember = document.createElement("label"); + const rememberBox = document.createElement("input"); + rememberBox.type = "checkbox"; + remember.appendChild(rememberBox); + remember.appendChild(document.createTextNode(" remember")); + const allow = document.createElement("button"); + allow.className = "primary"; + allow.textContent = "Allow"; + allow.setAttribute("aria-label", "Allow " + approval.toolName); + allow.addEventListener("click", () => vscode.postMessage({ command: "decideApproval", id: approval.id, decision: "allow", remember: rememberBox.checked })); + const deny = document.createElement("button"); + deny.textContent = "Deny"; + deny.setAttribute("aria-label", "Deny " + approval.toolName); + deny.addEventListener("click", () => vscode.postMessage({ command: "decideApproval", id: approval.id, decision: "deny", remember: rememberBox.checked })); + row.appendChild(allow); row.appendChild(deny); row.appendChild(remember); + card.appendChild(title); card.appendChild(desc); card.appendChild(row); + return card; + } + + function userInputCard(threadId, input) { + const card = document.createElement("div"); + card.className = "card"; + card.setAttribute("role", "group"); + card.setAttribute("aria-label", "Codewhale needs an answer"); + for (const question of input.questions || []) { + const title = document.createElement("div"); + title.className = "title"; + title.textContent = (question.header ? question.header + ": " : "") + question.question; + card.appendChild(title); + const selected = new Set(); + const answer = (label) => vscode.postMessage({ + command: "answerInput", inputId: input.id, + answers: [{ id: question.id, label: label, value: label }], + }); + for (const option of question.options || []) { + const btn = document.createElement("button"); + btn.className = "opt"; + btn.textContent = option.label; + if (option.description) { + const desc = document.createElement("span"); + desc.className = "opt-desc"; + desc.textContent = option.description; + btn.appendChild(desc); + } + if (question.multiSelect) { + btn.setAttribute("role", "checkbox"); + btn.setAttribute("aria-checked", "false"); + btn.addEventListener("click", () => { + const on = !selected.has(option.label); + if (on) { selected.add(option.label); btn.style.opacity = "0.6"; } + else { selected.delete(option.label); btn.style.opacity = ""; } + btn.setAttribute("aria-checked", on ? "true" : "false"); + }); + } else { + btn.addEventListener("click", () => answer(option.label)); + } + card.appendChild(btn); + } + if (question.multiSelect && (question.options || []).length > 0) { + const confirm = document.createElement("button"); + confirm.className = "primary"; + confirm.textContent = "Confirm"; + confirm.addEventListener("click", () => vscode.postMessage({ + command: "answerInput", inputId: input.id, + answers: Array.from(selected).map((label) => ({ id: question.id, label: label, value: label })), + })); + card.appendChild(confirm); + } + if (question.allowFreeText) { + const free = document.createElement("div"); + free.className = "row"; + const box = document.createElement("input"); + box.type = "text"; + box.placeholder = "Other…"; + box.setAttribute("aria-label", "Other answer for: " + question.question); + const send = document.createElement("button"); + send.textContent = "Send"; + send.addEventListener("click", () => { if (box.value.trim()) { answer(box.value.trim()); } }); + free.appendChild(box); free.appendChild(send); + card.appendChild(free); + } + } + return card; + } + + function renderItems(items) { + const seen = new Set(); + for (const view of items) { + seen.add(view.id); + const existing = itemEls.get(view.id); + if (!existing) { + itemEls.set(view.id, renderItem(view)); + } else if (existing.dataset.rev !== String(view.rev)) { + const fresh = renderItem(view); + existing.replaceWith(fresh); + itemEls.set(view.id, fresh); + } + } + for (const [id, el] of Array.from(itemEls)) { + if (!seen.has(id)) { el.remove(); itemEls.delete(id); streamBufs.delete(id); codeBlocks.delete(id); } + } + orderTranscript(items); + trimEmpty(); + scrollToBottom(); + } + + function orderTranscript(items) { + let cursor = transcript.firstChild; + for (const view of items) { + const el = itemEls.get(view.id); + if (!el) { continue; } + if (cursor === el) { cursor = el.nextSibling; continue; } + transcript.insertBefore(el, cursor); + } + } + + function renderItem(view) { + if (view.kind === "user_message") { + return wrap("user", "You", plainBubble(view.summary)); + } + if (view.kind === "agent_message") { + if (view.html !== undefined) { + streamBufs.delete(view.id); + const bubble = document.createElement("div"); + bubble.className = "bubble"; + bubble.innerHTML = view.html; + if (view.codeBlocks) { codeBlocks.set(view.id, view.codeBlocks); wireCodeButtons(bubble, view.id); } + return wrap("agent", "Codewhale", bubble, view); + } + const bubble = document.createElement("div"); + bubble.className = "bubble streaming"; + bubble.setAttribute("aria-busy", "true"); + bubble.textContent = view.streamText || ""; + streamBufs.set(view.id, bubble); + return wrap("agent", "Codewhale", bubble, view); + } + if (view.kind === "tool_call" || view.kind === "command_execution" || view.kind === "file_change") { + return wrap(view.kind, "", toolDetails(view), view); + } + if (view.kind === "error") { + return wrap("error", "Error", plainBubble(view.summary + (view.detail ? "\\n" + view.detail : "")), view); + } + return wrap("status", "", plainBubble(view.summary), view); + } + + function wrap(kind, who, body, view) { + const msg = document.createElement("div"); + msg.className = "msg " + kind; + msg.setAttribute("role", "group"); + msg.setAttribute("aria-label", who || kind.replace(/_/g, " ")); + if (view) { msg.dataset.rev = String(view.rev); } + if (who) { + const whoEl = document.createElement("div"); + whoEl.className = "who"; + whoEl.textContent = who; + msg.appendChild(whoEl); + } + msg.appendChild(body); + return msg; + } + + function plainBubble(text) { + const bubble = document.createElement("div"); + bubble.className = "bubble"; + bubble.textContent = text; + return bubble; + } + + function toolDetails(view) { + const details = document.createElement("details"); + details.className = "tool"; + const summary = document.createElement("summary"); + summary.textContent = toolLabel(view); + details.appendChild(summary); + const body = document.createElement("pre"); + body.textContent = view.detail || view.summary || ""; + details.appendChild(body); + // view.filePath is parsed extension-side (metadata.tool_input included) + // and validated again before anything is opened. + if (view.filePath) { + const open = document.createElement("button"); + open.textContent = "Open file"; + open.setAttribute("aria-label", "Open file " + view.filePath); + open.title = view.filePath; + open.style.margin = "4px 8px"; + open.addEventListener("click", () => vscode.postMessage({ command: "openFile", path: view.filePath })); + details.appendChild(open); + } + return details; + } + + function toolLabel(view) { + const icon = view.kind === "file_change" ? "✎ " : view.kind === "command_execution" ? "▶ " : "🔧 "; + return icon + (view.summary || view.kind); + } + + function wireCodeButtons(scope, itemId) { + for (const btn of scope.querySelectorAll("button.cb-copy, button.cb-insert")) { + const slot = Number(btn.dataset.cb); + btn.addEventListener("click", () => { + const blocks = codeBlocks.get(itemId) || []; + const code = blocks[slot] || ""; + vscode.postMessage({ command: btn.classList.contains("cb-copy") ? "copyCode" : "insertCode", code: code }); + }); + } + for (const link of scope.querySelectorAll("a[href]")) { + link.addEventListener("click", (e) => { + e.preventDefault(); + vscode.postMessage({ command: "openLink", url: link.getAttribute("href") }); + }); + } + } + + function appendDelta(itemId, text) { + let bubble = streamBufs.get(itemId); + if (!bubble) { return; } + bubble.textContent += text; + scrollToBottom(); + } + + function trimEmpty() { + const empty = transcript.querySelector(".empty"); + if (empty && transcript.children.length > 1) { empty.remove(); } + } + + function scrollToBottom() { + transcript.scrollTop = transcript.scrollHeight; + } + + function ensureEmpty() { + if (transcript.children.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "Start a task: attach context below and ask Codewhale. The same thread stays available in the terminal."; + transcript.appendChild(empty); + } + } + ensureEmpty(); + `; +} diff --git a/extensions/vscode/src/context.ts b/extensions/vscode/src/context.ts new file mode 100644 index 0000000000..c73cb0c87f --- /dev/null +++ b/extensions/vscode/src/context.ts @@ -0,0 +1,118 @@ +import * as vscode from "vscode"; + +/** A piece of editor context attached to the next prompt. */ +export interface ContextChip { + id: string; + kind: "selection" | "file" | "diagnostics"; + label: string; + detail?: string; + /** Full text included in the assembled prompt; may be truncated. */ + body: string; +} + +const MAX_BODY_CHARS = 8000; + +let chipCounter = 0; + +export function collectSelectionContext(): ContextChip | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.selection.isEmpty) { + return undefined; + } + const selection = editor.selection; + const text = editor.document.getText(selection); + if (text.trim().length === 0) { + return undefined; + } + const relative = workspaceRelativePath(editor.document.uri); + const startLine = selection.start.line + 1; + const endLine = selection.end.line + 1; + const lines = endLine - startLine + 1; + return { + id: `chip-${++chipCounter}`, + kind: "selection", + label: `${relative}:${startLine}-${endLine}`, + detail: `${lines} line${lines === 1 ? "" : "s"}`, + body: clip(`${text}\n`), + }; +} + +export function collectActiveFileContext(): ContextChip | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return undefined; + } + const relative = workspaceRelativePath(editor.document.uri); + const text = editor.document.getText(); + return { + id: `chip-${++chipCounter}`, + kind: "file", + label: relative, + detail: `${editor.document.lineCount} lines`, + body: clip(text), + }; +} + +export function collectDiagnosticsContext(): ContextChip | undefined { + const editor = vscode.window.activeTextEditor; + const entries: string[] = []; + const uris = editor + ? [editor.document.uri] + : vscode.workspace.textDocuments.map((document) => document.uri); + for (const uri of uris) { + for (const diagnostic of vscode.languages.getDiagnostics(uri)) { + if (diagnostic.severity > vscode.DiagnosticSeverity.Warning) { + continue; + } + const position = `${uri.path.split("/").pop()}:${diagnostic.range.start.line + 1}`; + const severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "error" : "warn"; + entries.push(`- [${severity}] ${position} ${diagnostic.message.split("\n")[0]}`); + if (entries.length >= 20) { + break; + } + } + } + if (entries.length === 0) { + return undefined; + } + return { + id: `chip-${++chipCounter}`, + kind: "diagnostics", + label: "Problems", + detail: `${entries.length} entrie${entries.length === 1 ? "" : "s"}`, + body: clip(entries.join("\n")), + }; +} + +/** Assemble the final prompt with context blocks the runtime model can read. */ +export function assemblePrompt(prompt: string, chips: ContextChip[]): string { + if (chips.length === 0) { + return prompt; + } + const sections = chips.map((chip) => { + const header = + chip.kind === "diagnostics" + ? "Diagnostics (problems panel)" + : `File \`${chip.label}\`${chip.kind === "selection" ? " (selected lines)" : ""}`; + return `--- ${header} ---\n${chip.body}`; + }); + return `${prompt}\n\n[Attached context]\n${sections.join("\n\n")}`; +} + +export function workspaceRelativePath(uri: vscode.Uri): string { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (!folder) { + return uri.fsPath; + } + const prefix = folder.uri.fsPath.endsWith("/") + ? folder.uri.fsPath + : `${folder.uri.fsPath}/`; + return uri.fsPath.startsWith(prefix) ? uri.fsPath.slice(prefix.length) : uri.fsPath; +} + +function clip(text: string): string { + if (text.length <= MAX_BODY_CHARS) { + return text; + } + return `${text.slice(0, MAX_BODY_CHARS)}\n… [truncated ${text.length - MAX_BODY_CHARS} chars]`; +} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 56c62edc86..7abe08bb53 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -1,67 +1,58 @@ import * as vscode from "vscode"; +import { checkConnection, listSnapshots, type ApiConfig, type ConnectionInfo } from "./api"; +import { ChatView } from "./chat"; import { - checkRuntime, - listSnapshots, - listThreadSummaries, openCodeWhaleTerminal, readRuntimeConfig, runtimeBaseUrl, startRuntimeTerminal, type RuntimeState, } from "./runtime"; +import { promptForToken, resolveToken } from "./secrets"; import { RuntimeStatusView } from "./status"; export function activate(context: vscode.ExtensionContext): void { const output = vscode.window.createOutputChannel("CodeWhale"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); const statusView = new RuntimeStatusView(); + const apiConfig = async (): Promise => { + const config = readRuntimeConfig(); + // SecretStorage is the only token source; `resolveToken` treats the + // deprecated `codewhale.runtimeToken` setting as a migration source and + // ignores a workspace-scoped one outright. + const token = await resolveToken(context); + return { baseUrl: runtimeBaseUrl(config), token }; + }; + const chatView = new ChatView(context, apiConfig, output); let autoRefreshTimer: ReturnType | undefined; let autoRefreshInFlight = false; + let lastConnectionKind: ConnectionInfo["kind"] | undefined; status.command = "codewhale.checkRuntime"; context.subscriptions.push(output, status); + // The chat lives in the secondary (right) sidebar on hosts that support it, + // and falls back to the activity bar on older ones. The manifest gates both + // containers on `codewhale.noSecondarySidebar`, so this key MUST be set at + // activation — an unset key is falsy, which would hide the activity-bar + // container AND leave the secondary panel unserved. Threshold matches the + // shipping Codex extension, which gates at runtime rather than via engines. + const [vsMajor = 0, vsMinor = 0] = vscode.version + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const supportsSecondarySidebar = vsMajor > 1 || (vsMajor === 1 && vsMinor >= 106); + void vscode.commands.executeCommand( + "setContext", + "codewhale.noSecondarySidebar", + !supportsSecondarySidebar, + ); + + // One ChatView instance serves both ids; only the gated one ever resolves. context.subscriptions.push( + vscode.window.registerWebviewViewProvider(ChatView.viewType, chatView), + vscode.window.registerWebviewViewProvider(ChatView.secondaryViewType, chatView), vscode.window.registerWebviewViewProvider(RuntimeStatusView.viewType, statusView), ); - const refreshAgentView = async (): Promise => { - const config = readRuntimeConfig(); - const threads = await listThreadSummaries(config); - statusView.updateThreads(threads, "Showing recent runtime threads."); - output.appendLine(`Loaded ${threads.length} runtime thread summaries.`); - }; - - const refreshSnapshots = async (): Promise => { - const config = readRuntimeConfig(); - const snapshots = await listSnapshots(config); - statusView.updateSnapshots(snapshots, "Showing recent restore points."); - output.appendLine(`Loaded ${snapshots.length} runtime restore points.`); - }; - - const refreshAgentViewDetails = async (showWarning: boolean): Promise => { - try { - await refreshAgentView(); - } catch (error: unknown) { - const detail = error instanceof Error ? error.message : String(error); - statusView.updateThreads([], "Runtime thread summaries unavailable."); - output.appendLine(`Runtime thread summaries unavailable: ${detail}`); - if (showWarning) { - void vscode.window.showWarningMessage(detail); - } - } - - try { - await refreshSnapshots(); - } catch (error: unknown) { - const detail = error instanceof Error ? error.message : String(error); - statusView.updateSnapshots([], detail); - output.appendLine(`Runtime restore points unavailable: ${detail}`); - if (showWarning) { - void vscode.window.showWarningMessage(detail); - } - } - }; - const updateStatus = (text: string, tooltip: string): void => { status.text = text; status.tooltip = tooltip; @@ -73,17 +64,34 @@ export function activate(context: vscode.ExtensionContext): void { logResult: boolean, ): Promise => { const config = readRuntimeConfig(); + const baseUrl = runtimeBaseUrl(config); if (showSpinner) { updateStatus("$(sync~spin) CodeWhale", "Checking CodeWhale runtime..."); } - const state = await checkRuntime(config); + let connection: ConnectionInfo; + try { + connection = await checkConnection(await apiConfig()); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + connection = { kind: "error", detail }; + } + const state: RuntimeState = { ...connection, baseUrl }; + statusView.update(state); + chatView.setConnection(connection); - switch (state.kind) { + const becameConnected = + connection.kind === "connected" && lastConnectionKind !== "connected"; + lastConnectionKind = connection.kind; + + switch (connection.kind) { case "connected": updateStatus("$(check) CodeWhale", state.detail); - await refreshAgentViewDetails(false); + await chatView.refreshThreads(); + if (becameConnected) { + await chatView.resyncAfterConnection(); + } break; case "auth-required": updateStatus("$(lock) CodeWhale", state.detail); @@ -108,7 +116,6 @@ export function activate(context: vscode.ExtensionContext): void { if (autoRefreshInFlight) { return; } - autoRefreshInFlight = true; try { await checkAndRefreshRuntime(false, false); @@ -122,17 +129,15 @@ export function activate(context: vscode.ExtensionContext): void { clearInterval(autoRefreshTimer); autoRefreshTimer = undefined; } - const intervalSeconds = readRuntimeConfig().agentViewRefreshIntervalSeconds; if (intervalSeconds === 0) { - output.appendLine("Agent View auto-refresh is disabled."); + output.appendLine("Auto-refresh is disabled."); return; } - autoRefreshTimer = setInterval(() => { void runAutoRefresh(); }, intervalSeconds * 1000); - output.appendLine(`Agent View auto-refresh scheduled every ${intervalSeconds}s.`); + output.appendLine(`Runtime auto-refresh scheduled every ${intervalSeconds}s.`); }; updateStatus("$(terminal) CodeWhale", "Check CodeWhale runtime"); @@ -144,47 +149,60 @@ export function activate(context: vscode.ExtensionContext): void { } }), vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("codewhale.agentViewRefreshIntervalSeconds")) { + if ( + event.affectsConfiguration("codewhale.agentViewRefreshIntervalSeconds") || + event.affectsConfiguration("codewhale.runtimeHost") || + event.affectsConfiguration("codewhale.runtimePort") || + event.affectsConfiguration("codewhale.runtimeToken") + ) { + lastConnectionKind = undefined; scheduleAutoRefresh(); + void checkAndRefreshRuntime(false, true); } }), ); context.subscriptions.push( vscode.commands.registerCommand("codewhale.openTerminal", () => { - const config = readRuntimeConfig(); - openCodeWhaleTerminal(config); - output.appendLine(`Opened CodeWhale terminal using ${config.commandPath}.`); + openCodeWhaleTerminal(readRuntimeConfig()); + output.appendLine(`Opened CodeWhale terminal using ${readRuntimeConfig().commandPath}.`); }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand("codewhale.startRuntime", () => { + vscode.commands.registerCommand("codewhale.startRuntime", async () => { const config = readRuntimeConfig(); - startRuntimeTerminal(config); const baseUrl = runtimeBaseUrl(config); + const token = await resolveToken(context); + + // Anything that answers on this address is already bound to the port; a + // second `serve` would only fail noisily, so report instead of starting. + let bound: ConnectionInfo | undefined; + try { + bound = await checkConnection({ baseUrl, token }); + } catch { + bound = undefined; + } + if (bound && bound.kind !== "offline") { + const detail = `A runtime is already listening at ${baseUrl}: ${bound.detail}`; + output.appendLine(detail); + void vscode.window.showInformationMessage(detail); + await checkAndRefreshRuntime(false, false); + return; + } + + startRuntimeTerminal(config, token); updateStatus("$(sync~spin) CodeWhale", `Runtime terminal started for ${baseUrl}`); output.appendLine(`Started CodeWhale runtime terminal at ${baseUrl}.`); void vscode.window.showInformationMessage(`CodeWhale runtime starting at ${baseUrl}`); }), - ); - - context.subscriptions.push( vscode.commands.registerCommand("codewhale.checkRuntime", async () => { return await checkAndRefreshRuntime(true, true); }), - ); - - context.subscriptions.push( vscode.commands.registerCommand("codewhale.refreshAgentView", async () => { - await refreshAgentViewDetails(true); + await chatView.refreshThreads(); }), - ); - - context.subscriptions.push( vscode.commands.registerCommand("codewhale.refreshSnapshots", async () => { try { - await refreshSnapshots(); + const snapshots = await listSnapshots(await apiConfig()); + statusView.updateSnapshots(snapshots, "Showing recent restore points."); } catch (error: unknown) { const detail = error instanceof Error ? error.message : String(error); statusView.updateSnapshots([], detail); @@ -192,16 +210,24 @@ export function activate(context: vscode.ExtensionContext): void { void vscode.window.showWarningMessage(detail); } }), - ); - - context.subscriptions.push( vscode.commands.registerCommand("codewhale.openRuntimeDocs", () => { void vscode.env.openExternal( - vscode.Uri.parse( - "https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_API.md", - ), + vscode.Uri.parse("https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_API.md"), ); }), + vscode.commands.registerCommand("codewhale.ask", async () => { + await chatView.askWithSelection(); + }), + vscode.commands.registerCommand("codewhale.newChat", async () => { + await chatView.reveal(); + await chatView.newThread(); + }), + vscode.commands.registerCommand("codewhale.setRuntimeToken", async () => { + const token = await promptForToken(context); + if (token !== undefined) { + await checkAndRefreshRuntime(true, true); + } + }), ); void vscode.commands.executeCommand("codewhale.checkRuntime"); diff --git a/extensions/vscode/src/markdown.ts b/extensions/vscode/src/markdown.ts new file mode 100644 index 0000000000..a4c28463b6 --- /dev/null +++ b/extensions/vscode/src/markdown.ts @@ -0,0 +1,134 @@ +/** + * Small, dependency-free Markdown renderer for chat transcripts. + * + * Scope is deliberately a safe subset — fenced code blocks, headings, inline + * code, bold, links (http/https only), lists, and horizontal rules. All text + * is HTML-escaped before any transform runs, so model output can never inject + * markup. Fenced blocks are also returned raw so the webview can offer + * Copy/Insert actions without round-tripping through the DOM. + */ + +export interface RenderedMarkdown { + html: string; + codeBlocks: string[]; +} + +export function renderMarkdown(source: string): RenderedMarkdown { + const codeBlocks: string[] = []; + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const blocks: string[] = []; + + let index = 0; + while (index < lines.length) { + const line = lines[index]; + const fence = matchFence(line); + if (fence !== undefined) { + const code: string[] = []; + index += 1; + while (index < lines.length && matchFence(lines[index]) === undefined) { + code.push(lines[index]); + index += 1; + } + index += 1; // consume the closing fence (or run off the end) + const slot = codeBlocks.length; + codeBlocks.push(code.join("\n")); + blocks.push(renderCodeBlock(slot, fence, code.join("\n"))); + continue; + } + + if (line.trim() === "") { + index += 1; + continue; + } + + if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + blocks.push(`
`); + index += 1; + continue; + } + + const heading = line.match(/^(#{1,4})\s+(.*)$/); + if (heading) { + const level = String(heading[1].length + 2); // demote: # -> h3 … keeps sidebar scale sane + blocks.push(`${inline(heading[2])}`); + index += 1; + continue; + } + + const bullet = line.match(/^\s*[-*+]\s+(.*)$/); + const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/); + if (bullet || numbered) { + const ordered = Boolean(numbered); + const items: string[] = []; + while (index < lines.length) { + const itemLine = lines[index].match(ordered ? /^\s*\d+[.)]\s+(.*)$/ : /^\s*[-*+]\s+(.*)$/); + if (!itemLine) { + break; + } + items.push(`
  • ${inline(itemLine[1])}
  • `); + index += 1; + } + blocks.push(ordered ? `
      ${items.join("")}
    ` : `
      ${items.join("")}
    `); + continue; + } + + const paragraph: string[] = []; + while ( + index < lines.length && + lines[index].trim() !== "" && + matchFence(lines[index]) === undefined && + !/^#{1,4}\s/.test(lines[index]) && + !/^\s*[-*+]\s+/.test(lines[index]) && + !/^\s*\d+[.)]\s+/.test(lines[index]) && + !/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index]) + ) { + paragraph.push(inline(lines[index])); + index += 1; + } + blocks.push(`

    ${paragraph.join("
    ")}

    `); + } + + return { html: blocks.join("\n"), codeBlocks }; +} + +function matchFence(line: string): string | undefined { + const match = line.match(/^\s*(```|~~~)\s*([\w+#.-]*)\s*$/); + return match ? match[2] : undefined; +} + +function renderCodeBlock(slot: number, language: string, code: string): string { + const label = language || "code"; + const firstLine = code.split("\n", 1)[0] ?? ""; + return ( + `
    ` + + `
    ${escapeHtml(label)}` + + `` + + `` + + `` + + `
    ` + + `
    ${escapeHtml(code)}
    ` + + `
    ` + ); +} + +function inline(text: string): string { + let result = escapeHtml(text); + const spans: string[] = []; + result = result.replace(/`([^`]+)`/g, (_match, code: string) => { + spans.push(`${code}`); + return `\u0000S${spans.length - 1}\u0000`; + }); + result = result.replace(/\*\*([^*]+)\*\*/g, "$1"); + result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); + result = result.replace(/\u0000S(\d+)\u0000/g, (_match, slot: string) => spans[Number(slot)] ?? ""); + return result; +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/extensions/vscode/src/runtime.ts b/extensions/vscode/src/runtime.ts index c0fadb1946..9c921b58bc 100644 --- a/extensions/vscode/src/runtime.ts +++ b/extensions/vscode/src/runtime.ts @@ -1,41 +1,19 @@ -import * as http from "node:http"; import * as vscode from "vscode"; +import type { SnapshotEntry, ThreadSummary } from "./api"; -export type RuntimeStateKind = "connected" | "offline" | "auth-required" | "error"; +export type { SnapshotEntry, ThreadSummary }; export interface RuntimeState { - kind: RuntimeStateKind; + kind: "connected" | "offline" | "auth-required" | "error"; baseUrl: string; detail: string; version?: string; } -export interface ThreadSummary { - id: string; - title: string; - preview: string; - model: string; - mode: string; - workspace?: string; - branch?: string; - head?: string; - dirty: boolean; - archived: boolean; - updatedAt: string; - latestTurnStatus?: string; -} - -export interface SnapshotEntry { - id: string; - label: string; - timestamp: number; -} - export interface RuntimeConfig { commandPath: string; host: string; port: number; - token?: string; agentViewRefreshIntervalSeconds: number; } @@ -44,13 +22,13 @@ export function readRuntimeConfig(): RuntimeConfig { const commandPath = config.get("commandPath", "codewhale").trim() || "codewhale"; const host = config.get("runtimeHost", "127.0.0.1").trim() || "127.0.0.1"; const port = config.get("runtimePort", 7878); - const token = config.get("runtimeToken", "").trim(); const interval = config.get("agentViewRefreshIntervalSeconds", 15); + // The bearer token is deliberately absent here: it resolves through + // `secrets.ts`, where SecretStorage wins over the deprecated setting. return { commandPath, host, port, - token: token.length > 0 ? token : undefined, agentViewRefreshIntervalSeconds: clampRefreshInterval(interval), }; } @@ -59,88 +37,20 @@ export function runtimeBaseUrl(config: RuntimeConfig): string { return `http://${config.host}:${config.port}`; } -export async function checkRuntime(config: RuntimeConfig): Promise { - const baseUrl = runtimeBaseUrl(config); - const health = await requestJson(`${baseUrl}/health`, config.token); - if (health.statusCode === 0) { - return { kind: "offline", baseUrl, detail: "Runtime is not reachable." }; - } - if (health.statusCode === 401) { - return { kind: "auth-required", baseUrl, detail: "Runtime requires a token." }; - } - if (health.statusCode !== 200) { - return { - kind: "error", - baseUrl, - detail: `Health check returned HTTP ${health.statusCode}.`, - }; - } - - const info = await requestJson(`${baseUrl}/v1/runtime/info`, config.token); - if (info.statusCode === 401) { - return { kind: "auth-required", baseUrl, detail: "Runtime info requires a token." }; - } - - // `/health` and `/v1/runtime/info` are intentionally unauthenticated, so a - // token-protected runtime answers both with HTTP 200. The info body carries - // the real signal: `auth_required`. Without it we would report "Connected" - // while every `/v1/*` data fetch then fails with 401. - if (readAuthRequired(info.body) && !config.token) { - return { - kind: "auth-required", - baseUrl, - detail: "Runtime requires a bearer token. Set codewhale.runtimeToken to connect.", - }; - } - - const version = readVersion(info.body); - return { - kind: "connected", - baseUrl, - detail: version ? `Connected to CodeWhale ${version}.` : "Connected to CodeWhale runtime.", - version, - }; -} - -export async function listThreadSummaries( - config: RuntimeConfig, - limit = 8, -): Promise { - const baseUrl = runtimeBaseUrl(config); - const response = await requestJson( - `${baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`, - config.token, - ); - - if (response.statusCode === 401) { - throw new Error("Thread summaries require the runtime bearer token."); - } - if (response.statusCode !== 200) { - throw new Error(`Thread summary returned HTTP ${response.statusCode}.`); - } - - return readThreadSummaries(response.body); -} - -export async function listSnapshots(config: RuntimeConfig, limit = 8): Promise { - const baseUrl = runtimeBaseUrl(config); - const response = await requestJson( - `${baseUrl}/v1/snapshots?limit=${encodeURIComponent(String(limit))}`, - config.token, - ); - - if (response.statusCode === 401) { - throw new Error("Restore points require the runtime bearer token."); - } - if (response.statusCode !== 200) { - throw new Error(`Restore points returned HTTP ${response.statusCode}.`); - } - - return readSnapshots(response.body); -} - -export function startRuntimeTerminal(config: RuntimeConfig): vscode.Terminal { - const terminal = vscode.window.createTerminal("CodeWhale Runtime"); +/** + * Start `codewhale serve` in a visible terminal. + * + * The bearer token never enters argv: a sent command line lands in the terminal + * buffer, the shell history file, and every local `ps`. The runtime accepts + * `CODEWHALE_RUNTIME_TOKEN` as the fallback for `--auth-token` + * (`crates/tui/src/lib.rs:1325-1327`), so it travels in the terminal's + * environment instead. + */ +export function startRuntimeTerminal(config: RuntimeConfig, token?: string): vscode.Terminal { + const terminal = vscode.window.createTerminal({ + name: "CodeWhale Runtime", + env: token ? { CODEWHALE_RUNTIME_TOKEN: token } : undefined, + }); const args = [ "serve", "--http", @@ -149,9 +59,6 @@ export function startRuntimeTerminal(config: RuntimeConfig): vscode.Terminal { "--port", String(config.port), ]; - if (config.token) { - args.push("--auth-token", shellQuote(config.token)); - } terminal.sendText(`${shellQuote(config.commandPath)} ${args.join(" ")}`); terminal.show(); return terminal; @@ -164,134 +71,6 @@ export function openCodeWhaleTerminal(config: RuntimeConfig): vscode.Terminal { return terminal; } -async function requestJson( - url: string, - token: string | undefined, -): Promise<{ statusCode: number; body: unknown }> { - try { - return await new Promise<{ statusCode: number; body: unknown }>((resolve, reject) => { - const request = http.get( - url, - { - timeout: 2500, - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }, - (response) => { - let body = ""; - response.setEncoding("utf8"); - response.on("data", (chunk: string) => { - body += chunk; - }); - response.on("end", () => { - resolve({ - statusCode: response.statusCode ?? 0, - body: parseJson(body), - }); - }); - }, - ); - - request.on("timeout", () => { - request.destroy(new Error("Runtime check timed out.")); - }); - request.on("error", reject); - }); - } catch (error: unknown) { - const detail = error instanceof Error ? error.message : String(error); - return { statusCode: 0, body: { error: detail } }; - } -} - -function parseJson(raw: string): unknown { - try { - return JSON.parse(raw); - } catch { - return undefined; - } -} - -function readVersion(value: unknown): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const version = (value as { version?: unknown }).version; - return typeof version === "string" ? version : undefined; -} - -function readAuthRequired(value: unknown): boolean { - if (!value || typeof value !== "object") { - return false; - } - return (value as { auth_required?: unknown }).auth_required === true; -} - -function readThreadSummaries(value: unknown): ThreadSummary[] { - if (!Array.isArray(value)) { - return []; - } - - return value.flatMap((item) => { - if (!item || typeof item !== "object") { - return []; - } - const record = item as Record; - const id = readString(record.id); - if (!id) { - return []; - } - - return [ - { - id, - title: readString(record.title) ?? "New Thread", - preview: readString(record.preview) ?? "", - model: readString(record.model) ?? "unknown", - mode: readString(record.mode) ?? "agent", - workspace: readString(record.workspace), - branch: readString(record.branch), - head: readString(record.head), - dirty: readBoolean(record.dirty), - archived: record.archived === true, - updatedAt: readString(record.updated_at) ?? "", - latestTurnStatus: readString(record.latest_turn_status), - }, - ]; - }); -} - -function readSnapshots(value: unknown): SnapshotEntry[] { - if (!Array.isArray(value)) { - return []; - } - - return value.flatMap((item) => { - if (!item || typeof item !== "object") { - return []; - } - const record = item as Record; - const id = readString(record.id); - const label = readString(record.label); - const timestamp = readNumber(record.timestamp); - if (!id || !label || timestamp === undefined) { - return []; - } - - return [{ id, label, timestamp }]; - }); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readBoolean(value: unknown): boolean { - return value === true; -} - function clampRefreshInterval(value: number): number { if (!Number.isFinite(value)) { return 15; diff --git a/extensions/vscode/src/secrets.ts b/extensions/vscode/src/secrets.ts new file mode 100644 index 0000000000..a2682d3daf --- /dev/null +++ b/extensions/vscode/src/secrets.ts @@ -0,0 +1,81 @@ +import * as vscode from "vscode"; + +const SECRET_KEY = "codewhale.runtimeToken"; +const SETTING_KEY = "runtimeToken"; + +let warnedAboutWorkspaceToken = false; + +/** Trim a setting value that arrived as `unknown` from a trust boundary. */ +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Resolve the runtime bearer token. SecretStorage is authoritative; the legacy + * `codewhale.runtimeToken` setting is a one-way migration source only, which is + * what `package.json`'s deprecation message and `README.md` promise. + * + * Only a *user-level* value is migrated. A workspace- or folder-scoped value is + * attacker-controlled input: `codewhale.runtimeHost` is workspace-settable too, + * so a repo-local `.vscode/settings.json` that supplied both would make merely + * opening the repository ship a bearer token to a host of its choosing. Such a + * value is ignored, never adopted. + */ +export async function resolveToken(context: vscode.ExtensionContext): Promise { + const stored = nonEmptyString(await context.secrets.get(SECRET_KEY)); + if (stored) { + return stored; + } + + const config = vscode.workspace.getConfiguration("codewhale"); + const inspected = config.inspect(SETTING_KEY); + const userToken = nonEmptyString(inspected?.globalValue); + if (!userToken) { + const workspaceToken = + nonEmptyString(inspected?.workspaceValue) ?? + nonEmptyString(inspected?.workspaceFolderValue); + if (workspaceToken && !warnedAboutWorkspaceToken) { + warnedAboutWorkspaceToken = true; + void vscode.window.showWarningMessage( + "Ignoring codewhale.runtimeToken from workspace settings: a workspace cannot supply the runtime bearer token. Use CodeWhale: Set Runtime Token.", + ); + } + return undefined; + } + + await context.secrets.store(SECRET_KEY, userToken); + // One-way migration: drop the plaintext copy so it stops riding Settings Sync. + try { + await config.update(SETTING_KEY, undefined, vscode.ConfigurationTarget.Global); + } catch { + // A read-only settings.json must not cost the user a working token; the + // secret is already stored, so keep going and leave the plaintext behind. + } + return userToken; +} + +export async function storeToken(context: vscode.ExtensionContext, token: string): Promise { + await context.secrets.store(SECRET_KEY, token.trim()); +} + +export async function promptForToken(context: vscode.ExtensionContext): Promise { + const entered = await vscode.window.showInputBox({ + prompt: "Codewhale runtime bearer token (stored in VS Code secret storage)", + password: true, + ignoreFocusOut: true, + }); + if (entered === undefined) { + return undefined; + } + const token = entered.trim(); + if (token.length === 0) { + await context.secrets.delete(SECRET_KEY); + return undefined; + } + await storeToken(context, token); + return token; +} diff --git a/extensions/vscode/src/sse.ts b/extensions/vscode/src/sse.ts new file mode 100644 index 0000000000..2ffec0d40f --- /dev/null +++ b/extensions/vscode/src/sse.ts @@ -0,0 +1,112 @@ +/** + * Minimal text/event-stream parser for `GET /v1/threads/{id}/events`. + * + * Pure and dependency-free: callers feed raw network chunks in, parsed frames + * come out. The Codewhale runtime encodes each event as a JSON object in the + * frame data, so a frame with parseable JSON yields one RuntimeEvent; frames + * without JSON (heartbeats, comments) yield nothing. + */ + +export interface RuntimeEvent { + seq: number; + previousSeq?: number; + event: string; + threadId?: string; + turnId?: string; + itemId?: string; + timestamp?: string; + payload: unknown; +} + +export class SseParser { + private buffer = ""; + + /** Feed one raw chunk; returns the complete events it finished. */ + push(chunk: string): RuntimeEvent[] { + this.buffer += chunk; + const events: RuntimeEvent[] = []; + let boundary = this.nextBoundary(); + while (boundary !== -1) { + const frame = this.buffer.slice(0, boundary.index); + this.buffer = this.buffer.slice(boundary.index + boundary.length); + const event = parseFrame(frame); + if (event) { + events.push(event); + } + boundary = this.nextBoundary(); + } + return events; + } + + private nextBoundary(): { index: number; length: number } | -1 { + const lf = this.buffer.indexOf("\n\n"); + const crlf = this.buffer.indexOf("\r\n\r\n"); + if (lf === -1 && crlf === -1) { + return -1; + } + if (crlf === -1 || (lf !== -1 && lf < crlf)) { + return { index: lf, length: 2 }; + } + return { index: crlf, length: 4 }; + } +} + +export function parseFrame(frame: string): RuntimeEvent | undefined { + let data = ""; + for (const rawLine of frame.split(/\r?\n/)) { + if (rawLine === "" || rawLine.startsWith(":")) { + continue; + } + const colon = rawLine.indexOf(":"); + const field = colon === -1 ? rawLine : rawLine.slice(0, colon); + let value = colon === -1 ? "" : rawLine.slice(colon + 1); + if (value.startsWith(" ")) { + value = value.slice(1); + } + if (field === "data") { + data += (data ? "\n" : "") + value; + } + // `event`, `id`, and `retry` are ignored: the runtime envelope carries the + // event name in its own `event` field, which is the stable contract. + } + if (!data) { + return undefined; + } + return readEvent(data); +} + +function readEvent(data: string): RuntimeEvent | undefined { + let body: unknown; + try { + body = JSON.parse(data); + } catch { + return undefined; + } + if (!body || typeof body !== "object") { + return undefined; + } + const record = body as Record; + const seq = readNumber(record.seq); + const event = readString(record.event); + if (seq === undefined || !event) { + return undefined; + } + return { + seq, + previousSeq: readNumber(record.previous_seq), + event, + threadId: readString(record.thread_id), + turnId: readString(record.turn_id), + itemId: readString(record.item_id), + timestamp: readString(record.timestamp) ?? readString(record.created_at), + payload: record.payload, + }; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/extensions/vscode/src/status.ts b/extensions/vscode/src/status.ts index af91b2ea51..d28c0d4092 100644 --- a/extensions/vscode/src/status.ts +++ b/extensions/vscode/src/status.ts @@ -1,3 +1,4 @@ +import * as crypto from "node:crypto"; import * as vscode from "vscode"; import type { RuntimeState, SnapshotEntry, ThreadSummary } from "./runtime"; @@ -185,11 +186,7 @@ function escapeHtml(value: string): string { .replace(/"/g, """); } +/** CSP nonces must be unguessable; `Math.random()` is not. Matches `chat.ts`. */ function makeNonce(): string { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let nonce = ""; - for (let index = 0; index < 32; index += 1) { - nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); - } - return nonce; + return crypto.randomBytes(16).toString("hex"); } diff --git a/extensions/vscode/src/test/api.test.ts b/extensions/vscode/src/test/api.test.ts new file mode 100644 index 0000000000..018036b5aa --- /dev/null +++ b/extensions/vscode/src/test/api.test.ts @@ -0,0 +1,358 @@ +import assert from "node:assert/strict"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; +import { + answerUserInput, + ApiError, + checkConnection, + ConflictError, + getThreadDetail, + interruptTurn, + listThreadSummaries, + openEventStream, + resolveApproval, + startTurn, + steerTurn, + type ApiConfig, + type RuntimeEvent, +} from "../api"; + +describe("api client", () => { + let server: http.Server; + let baseUrl: string; + const seen: { path?: string; auth?: string; method?: string; body?: string } = {}; + + before(async () => { + server = http.createServer((request, response) => { + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString("utf8"); + }); + request.on("end", () => { + seen.path = request.url; + seen.auth = request.headers.authorization; + seen.method = request.method; + seen.body = body; + + if (request.url === "/health") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end("{}"); + return; + } + if (request.url === "/v1/runtime/info") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ version: "0.9.12", auth_required: true })); + return; + } + if (request.url?.startsWith("/v1/threads/summary")) { + if (seen.auth !== "Bearer sekrit") { + response.writeHead(401, { "Content-Type": "application/json" }); + response.end("{}"); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify([ + { + id: "thr_1", + title: "Implement chat", + preview: "Let me start…", + model: "deepseek-v4-pro", + mode: "agent", + branch: "main", + head: "abc1234", + dirty: true, + archived: false, + updated_at: "2026-06-06T05:43:00Z", + latest_turn_status: "completed", + }, + ]), + ); + return; + } + if (request.url === "/v1/threads/thr_1" && request.method === "GET") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify({ + thread: { id: "thr_1", model: "deepseek-v4-pro", updated_at: "2026-06-06T05:43:00Z" }, + turns: [{ id: "turn_1", status: "completed" }], + items: [ + { id: "item_1", turn_id: "turn_1", kind: "user_message", status: "completed", summary: "hi" }, + { + id: "item_2", + turn_id: "turn_1", + kind: "agent_message", + status: "completed", + summary: "**done**", + }, + ], + latest_seq: 12, + pending_approvals: [ + { id: "ap_1", turn_id: "turn_1", tool_name: "shell", description: "rm -rf /" }, + ], + pending_user_inputs: [ + { + id: "ui_1", + turn_id: "turn_1", + request: { + questions: [ + { + header: "Approach", + id: "q1", + question: "Which way?", + options: [{ label: "Fast", description: "quick" }], + allow_free_text: true, + }, + ], + }, + }, + ], + }), + ); + return; + } + if (request.url === "/v1/threads/thr_1/turns" && request.method === "POST") { + if (!body.includes("operation_key")) { + response.writeHead(400, { "Content-Type": "application/json" }); + response.end("{}"); + return; + } + // The real contract: `start_thread_turn` answers 201 CREATED + // (crates/tui/src/runtime_api.rs:4613-4631). + response.writeHead(201, { "Content-Type": "application/json" }); + response.end( + JSON.stringify({ + thread: { id: "thr_1" }, + turn: { id: "turn_2", status: "queued" }, + }), + ); + return; + } + // Every 2xx is a success, so pin the edges of the range as well. + const rangeMatch = /^\/v1\/threads\/thr_(200|201|202)\/turns$/.exec(request.url ?? ""); + if (rangeMatch && request.method === "POST") { + response.writeHead(Number(rangeMatch[1]), { "Content-Type": "application/json" }); + response.end( + JSON.stringify({ + thread: { id: `thr_${rangeMatch[1]}` }, + turn: { id: "turn_range", status: "queued" }, + }), + ); + return; + } + if (request.url === "/v1/threads/thr_500/turns") { + response.writeHead(500, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: { code: "internal", message: "engine exploded" } })); + return; + } + if (request.url === "/v1/threads/thr_401/turns") { + response.writeHead(401, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: { message: "missing bearer token" } })); + return; + } + if (request.url === "/v1/threads/thr_1/turns/turn_idle/interrupt") { + response.writeHead(409, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: { message: "No active turn for thread 'thr_1'" } })); + return; + } + if (request.url === "/v1/threads/thr_1/turns/turn_2/steer") { + response.writeHead(202, { "Content-Type": "application/json" }); + response.end("{}"); + return; + } + if (request.url === "/v1/threads/thr_1/turns/turn_2/interrupt") { + response.writeHead(202, { "Content-Type": "application/json" }); + response.end("{}"); + return; + } + if (request.url === "/v1/approvals/ap_1") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ decision: JSON.parse(body).decision })); + return; + } + if (request.url === "/v1/user-input/thr_1/ui_1") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end("{}"); + return; + } + if (request.url === "/v1/threads/thr_conflict/turns") { + response.writeHead(409, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: { code: "operation_key_conflict", message: "key reuse" } })); + return; + } + response.writeHead(404, { "Content-Type": "application/json" }); + response.end("{}"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + after(() => { + server.close(); + }); + + const config = (token?: string): ApiConfig => ({ baseUrl, token }); + + it("reports connected with version", async () => { + const info = await checkConnection(config("sekrit")); + assert.equal(info.kind, "connected"); + assert.equal(info.version, "0.9.12"); + }); + + it("reports auth-required when info demands a token and none is set", async () => { + const strict = http.createServer((request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ auth_required: true })); + request.on("data", () => undefined); + }); + await new Promise((resolve) => strict.listen(0, "127.0.0.1", resolve)); + const strictBase = `http://127.0.0.1:${(strict.address() as AddressInfo).port}`; + const info = await checkConnection({ baseUrl: strictBase }); + assert.equal(info.kind, "auth-required"); + strict.close(); + }); + + it("lists thread summaries with the bearer token", async () => { + const threads = await listThreadSummaries(config("sekrit")); + assert.equal(seen.auth, "Bearer sekrit"); + assert.equal(threads.length, 1); + assert.equal(threads[0].id, "thr_1"); + assert.equal(threads[0].dirty, true); + }); + + it("hydrates thread detail with pending approvals and inputs", async () => { + const detail = await getThreadDetail(config(), "thr_1"); + assert.equal(detail.latestSeq, 12); + assert.equal(detail.items.length, 2); + assert.equal(detail.pendingApprovals[0].toolName, "shell"); + assert.equal(detail.pendingUserInputs[0].questions[0].options[0].label, "Fast"); + assert.equal(detail.pendingUserInputs[0].questions[0].allowFreeText, true); + }); + + it("starts a turn with an idempotency key and accepts the runtime's 201", async () => { + const result = await startTurn(config(), "thr_1", { + prompt: "do the thing", + operationKey: "op-123", + }); + assert.ok(seen.body?.includes("operation_key")); + assert.ok(seen.body?.includes("do the thing")); + assert.equal(result.turn.id, "turn_2"); + }); + + it("accepts every 2xx, not a hand-picked list of codes", async () => { + for (const status of ["200", "201", "202"]) { + const result = await startTurn(config(), `thr_${status}`, { prompt: "x", operationKey: "k" }); + assert.equal(result.turn.id, "turn_range", `HTTP ${status} should be a success`); + } + }); + + it("surfaces the runtime's message on a 5xx", async () => { + await assert.rejects( + startTurn(config(), "thr_500", { prompt: "x", operationKey: "k" }), + (error: unknown) => { + assert.ok(error instanceof ApiError); + assert.equal((error as ApiError).statusCode, 500); + assert.ok((error as ApiError).message.includes("engine exploded")); + return true; + }, + ); + }); + + it("signals auth-required on a mutating 401", async () => { + await assert.rejects( + startTurn(config(), "thr_401", { prompt: "x", operationKey: "k" }), + (error: unknown) => { + assert.ok(error instanceof ApiError); + assert.equal((error as ApiError).statusCode, 401); + assert.ok((error as ApiError).message.includes("requires the runtime token")); + return true; + }, + ); + }); + + it("steers and interrupts", async () => { + await steerTurn(config(), "thr_1", "turn_2", "focus on tests"); + assert.ok(seen.path?.includes("/steer")); + assert.ok(seen.body?.includes("focus on tests")); + const result = await interruptTurn(config(), "thr_1", "turn_2"); + assert.ok(seen.path?.endsWith("/interrupt")); + assert.equal(result, "interrupted"); + }); + + it("treats a 409 interrupt as nothing to stop, not a failure", async () => { + const result = await interruptTurn(config(), "thr_1", "turn_idle"); + assert.equal(result, "not-running"); + }); + + it("resolves approvals and answers user input", async () => { + await resolveApproval(config(), "ap_1", "deny", true); + assert.ok(seen.body?.includes('"deny"')); + assert.ok(seen.body?.includes('"remember":true')); + await answerUserInput(config(), "thr_1", "ui_1", [{ id: "q1", label: "Fast", value: "Fast" }]); + assert.ok(seen.body?.includes("ui_1") || seen.path?.includes("ui_1")); + assert.ok(seen.body?.includes("Fast")); + }); + + it("surfaces 409 as a typed conflict with server detail", async () => { + const failing: ApiConfig = { baseUrl }; + const promise = startTurn(failing, "thr_conflict", { prompt: "x", operationKey: "k" }); + await assert.rejects(promise, (error: unknown) => { + assert.ok(error instanceof ApiError); + // A conflict is its own signal so callers can say "a turn is already + // running" rather than reporting a generic HTTP failure. + assert.ok(error instanceof ConflictError); + assert.equal((error as ApiError).statusCode, 409); + assert.equal((error as ApiError).detail, "key reuse"); + assert.ok((error as ApiError).message.includes("key reuse")); + return true; + }); + }); + + it("streams and parses SSE events", async () => { + const received: RuntimeEvent[] = []; + let finish!: () => void; + const done = new Promise((resolve) => { + finish = resolve; + }); + + const sseServer = http.createServer((request, response) => { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write('data: {"seq":1,"event":"item.started","item_id":"i1","payload":{"kind":"agent_message"}}\n\n'); + response.write('data: {"seq":2,"event":"item.delta","item_id":"i1","payload":{"delta":"hel"}}\n\n'); + setTimeout(() => { + response.write('data: {"seq":3,"event":"item.completed","item_id":"i1","payload":{"summary":"hello"}}\n\n'); + response.end(); + }, 20); + }); + await new Promise((resolve) => sseServer.listen(0, "127.0.0.1", resolve)); + const sseBase = `http://127.0.0.1:${(sseServer.address() as AddressInfo).port}`; + + const stream = openEventStream({ baseUrl: sseBase }, "thr_9", 0); + let streamError: Error | undefined; + stream.onEvent = (event) => { + received.push(event); + if (received.length === 3) { + finish(); + } + }; + // The server ends the response after the third event, so a trailing + // "stream closed" error is expected and must not loop or throw. + stream.onError = (error) => { + streamError = error; + }; + await done; + assert.deepEqual( + received.map((event) => [event.seq, event.event]), + [ + [1, "item.started"], + [2, "item.delta"], + [3, "item.completed"], + ], + ); + assert.ok(streamError); + stream.close(); + sseServer.close(); + }); +}); diff --git a/extensions/vscode/src/test/markdown.test.ts b/extensions/vscode/src/test/markdown.test.ts new file mode 100644 index 0000000000..ef077fd8ba --- /dev/null +++ b/extensions/vscode/src/test/markdown.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderMarkdown } from "../markdown"; + +describe("renderMarkdown", () => { + it("escapes HTML so model output cannot inject markup", () => { + const { html } = renderMarkdown(''); + assert.ok(!html.includes("