From 82f6f8cccc0412d9581a62ea75b5a0b892bd00b4 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sun, 6 Sep 2026 21:51:19 -0700 Subject: [PATCH 1/2] feat(vscode): interactive chat sidebar over the Engine Runtime API Replace the attach-only scaffold with a working agent chat extension: - Chat sidebar (codewhale.chat): create/switch/resume threads, live streaming over the replayable SSE contract, inline tool approvals, clarification questions, steer, and interrupt - Editor context chips (selection / active file / diagnostics) assembled into the prompt; CodeWhale: Ask Codewhale command, ctrl+alt+c keybinding, and editor context menu entry - Safe Markdown rendering (escaped subset) with per-block Copy and Insert-at-cursor actions - Runtime bearer tokens move to VS Code SecretStorage (CodeWhale: Set Runtime Token) with settings migration - Pure /v1 client (api.ts), SSE parser (sse.ts), and renderer (markdown.ts) split out of VS Code for direct testing The runtime remains the single turn/event owner; the extension only renders and routes. Full-feature flows (diff review, model switching, account sign-in) stay with the runtime's embedded browser client until their contracts land. Proof: extensions/vscode npm test -> 25 passed, 0 failed; packaged codewhale-vscode-0.10.0.vsix (95 KB, tests excluded); compiled client smoke-tested against the release runtime binary (health, auth-required detection, thread summaries, create, detail hydration, snapshots) with no model turns executed. --- extensions/vscode/.vscodeignore | 6 + extensions/vscode/README.md | 76 +- extensions/vscode/package.json | 57 +- extensions/vscode/src/api.ts | 657 ++++++++++ extensions/vscode/src/chat.ts | 1310 +++++++++++++++++++ extensions/vscode/src/context.ts | 118 ++ extensions/vscode/src/extension.ts | 133 +- extensions/vscode/src/markdown.ts | 134 ++ extensions/vscode/src/runtime.ts | 235 +--- extensions/vscode/src/secrets.ts | 49 + extensions/vscode/src/sse.ts | 112 ++ extensions/vscode/src/test/api.test.ts | 287 ++++ extensions/vscode/src/test/markdown.test.ts | 56 + extensions/vscode/src/test/sse.test.ts | 78 ++ 14 files changed, 2971 insertions(+), 337 deletions(-) create mode 100644 extensions/vscode/.vscodeignore create mode 100644 extensions/vscode/src/api.ts create mode 100644 extensions/vscode/src/chat.ts create mode 100644 extensions/vscode/src/context.ts create mode 100644 extensions/vscode/src/markdown.ts create mode 100644 extensions/vscode/src/secrets.ts create mode 100644 extensions/vscode/src/sse.ts create mode 100644 extensions/vscode/src/test/api.test.ts create mode 100644 extensions/vscode/src/test/markdown.test.ts create mode 100644 extensions/vscode/src/test/sse.test.ts 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/package.json b/extensions/vscode/package.json index 711a869b16..3e5800a94a 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -1,7 +1,7 @@ { "name": "codewhale-vscode", "displayName": "CodeWhale", - "description": "Official CodeWhale VS Code integration scaffold for local runtime attach and terminal launch.", + "description": "Official CodeWhale VS Code extension: agentic chat sidebar over the local Engine Runtime API, with editor context, streaming turns, approvals, and terminal parity.", "version": "0.9.12", "publisher": "codewhale", "license": "MIT", @@ -15,26 +15,38 @@ "vscode": "^1.90.0" }, "categories": [ + "AI", + "Chat", "Other" ], "activationEvents": [ + "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.chat", "onView:codewhale.runtimeStatus" ], "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,6 +72,23 @@ "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", "properties": { @@ -83,14 +112,16 @@ "codewhale.runtimeToken": { "type": "string", "default": "", - "description": "Optional bearer token for authenticated runtime endpoints." + "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." } } }, @@ -105,10 +136,15 @@ }, "views": { "codewhale": [ + { + "type": "webview", + "id": "codewhale.chat", + "name": "Chat" + }, { "type": "webview", "id": "codewhale.runtimeStatus", - "name": "Agent View" + "name": "Runtime" } ] } @@ -116,6 +152,7 @@ "scripts": { "compile": "tsc -p ./", "check": "npm run compile", + "test": "npm run compile && node --test out/test/*.test.js", "package": "vsce package --no-dependencies" }, "devDependencies": { diff --git a/extensions/vscode/src/api.ts b/extensions/vscode/src/api.ts new file mode 100644 index 0000000000..3ca54b3e9a --- /dev/null +++ b/extensions/vscode/src/api.ts @@ -0,0 +1,657 @@ +/** + * 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 (health.statusCode !== 200) { + 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 }, + ); + if (response.statusCode === 401) { + throw new ApiError("Thread summaries require the runtime token.", 401); + } + if (response.statusCode !== 200) { + throw new ApiError(`Thread summary returned HTTP ${response.statusCode}.`, response.statusCode); + } + 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 }, + ); + if (response.statusCode !== 200) { + throw new ApiError(`Thread detail returned HTTP ${response.statusCode}.`, response.statusCode); + } + 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, + }); + if (response.statusCode !== 200 && response.statusCode !== 201) { + throw new ApiError(`Create thread returned HTTP ${response.statusCode}.`, response.statusCode); + } + 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 }, + ); + if (response.statusCode !== 200 && response.statusCode !== 202) { + throw new ApiError( + `Start turn returned HTTP ${response.statusCode}.`, + response.statusCode, + readErrorDetail(response.body), + ); + } + 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 }, + ); + if (response.statusCode !== 200 && response.statusCode !== 202) { + throw new ApiError(`Steer returned HTTP ${response.statusCode}.`, response.statusCode); + } +} + +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 !== 200 && response.statusCode !== 202) { + throw new ApiError(`Interrupt returned HTTP ${response.statusCode}.`, response.statusCode); + } +} + +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, + }, + ); + if (response.statusCode !== 200 && response.statusCode !== 202) { + throw new ApiError(`Approval returned HTTP ${response.statusCode}.`, response.statusCode); + } +} + +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 }, + ); + if (response.statusCode !== 200 && response.statusCode !== 202) { + throw new ApiError(`User input returned HTTP ${response.statusCode}.`, response.statusCode); + } +} + +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 }, + ); + if (response.statusCode !== 200) { + throw new ApiError(`Restore points returned HTTP ${response.statusCode}.`, response.statusCode); + } + 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 (response.statusCode !== 200) { + response.resume(); + stream.onError( + new ApiError( + `Event stream returned HTTP ${response.statusCode}.`, + response.statusCode ?? 0, + ), + ); + 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; + } +} + +interface RequestResult { + statusCode: number; + body: unknown; +} + +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..7620786414 --- /dev/null +++ b/extensions/vscode/src/chat.ts @@ -0,0 +1,1310 @@ +import * as crypto from "node:crypto"; +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 { renderMarkdown } from "./markdown"; + +/** + * 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 ItemView { + id: string; + kind: string; + status?: string; + turnId?: string; + summary: string; + detail?: string; + metadata?: Record; + /** Rendered markdown for completed agent messages. */ + html?: string; + codeBlocks?: string[]; + /** In-progress agent text (plain, re-rendered on completion). */ + streamText?: string; + rev: number; +} + +interface SyncMessage { + type: "sync"; + connection?: ConnectionInfo; + threads: ThreadSummary[]; + activeThreadId?: string; + model?: string; + streaming: boolean; + chips: ContextChip[]; + approvals: PendingApproval[]; + inputs: PendingUserInput[]; + items: ItemView[]; +} + +type OutboundMessage = SyncMessage | { type: "delta"; itemId: string; text: string } | { type: "focusComposer" }; + +export class ChatView implements vscode.WebviewViewProvider { + public static readonly viewType = "codewhale.chat"; + + 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 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; + 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(`${ChatView.viewType}.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.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) { + return; + } + if (!this.activeThreadId) { + await this.newThread(); + if (!this.activeThreadId) { + return; + } + } + const threadId = this.activeThreadId; + const assembled = assemblePrompt(prompt, this.chips); + this.chips = []; + try { + const result = await startTurn(await this.configProvider(), threadId, { + prompt: assembled, + operationKey: crypto.randomUUID(), + }); + this.streamingTurnId = result.turn.id; + this.addLocalUserMessage(prompt); + void this.openStream(threadId, this.lastSeq); + this.postSync(); + } catch (error) { + 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); + } + + private async openFileAtPath(path: string): Promise { + if (!path) { + return; + } + const candidates = [ + vscode.Uri.file(path), + ...(vscode.workspace.workspaceFolders ?? []).map((folder) => + vscode.Uri.joinPath(folder.uri, path), + ), + ]; + for (const uri of candidates) { + 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: ${path}`); + } + + // ---- 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": { + 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.completed": + case "turn.interrupt_requested": { + if (event.turnId && event.turnId === this.streamingTurnId) { + this.streamingTurnId = undefined; + 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; + } + 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(() => { + if (this.activeThreadId === threadId && !this.stream) { + this.openStream(threadId, this.lastSeq); + } + }, 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); + const isTerminal = event === "item.completed" || item.status === "completed"; + const streamText = existing?.streamText; + let view: ItemView; + if (item.kind === "agent_message" && !isTerminal) { + view = { + id: item.id, + kind: item.kind, + status: item.status, + turnId: item.turnId ?? existing?.turnId, + summary: item.summary || streamText || "", + streamText: item.summary || streamText || "", + rev: (existing?.rev ?? 0) + 1, + }; + } else if (item.kind === "agent_message" && isTerminal) { + const finalText = item.summary || streamText || ""; + const rendered = renderMarkdown(finalText); + view = { + id: item.id, + kind: item.kind, + status: item.status, + turnId: item.turnId ?? existing?.turnId, + summary: finalText, + html: rendered.html, + codeBlocks: rendered.codeBlocks, + rev: (existing?.rev ?? 0) + 1, + }; + } else { + view = { + id: item.id, + kind: item.kind, + status: item.status, + turnId: item.turnId ?? existing?.turnId, + summary: item.summary || existing?.summary || "", + detail: item.detail ?? existing?.detail, + metadata: item.metadata ?? existing?.metadata, + rev: (existing?.rev ?? 0) + 1, + }; + } + this.items.set(item.id, view); + 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, + 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 +
+
+
+
+
+ +
+
+ + + + +
+ +
+ + +`; + } +} + +function statusForEvent(event: string): string { + if (event === "item.completed") { + return "completed"; + } + if (event === "item.failed") { + return "failed"; + } + if (event === "item.interrupted") { + return "interrupted"; + } + return "in_progress"; +} + +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); } + 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; } + #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", () => 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(); + } + }); + + function sendPrompt() { + const text = promptBox.value.trim(); + if (!text) { return; } + promptBox.value = ""; + vscode.postMessage({ command: "sendPrompt", text }); + } + 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(); } + }); + vscode.postMessage({ command: "ready" }); + + function renderSync(msg) { + state = msg; + renderConnection(msg); + renderThreads(msg); + renderChips(msg.chips || []); + renderAttention(msg); + renderItems(msg.items || []); + document.getElementById("steer-box").classList.toggle("hidden", !msg.streaming); + document.getElementById("btn-interrupt").classList.toggle("hidden", !msg.streaming); + 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" : ""); + 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); + el.addEventListener("click", () => vscode.postMessage({ command: "selectThread", id: thread.id })); + threadsList.appendChild(el); + } + } + + function renderChips(chips) { + chipsRow.textContent = ""; + for (const chip of chips) { + const el = document.createElement("span"); + el.className = "chip"; + const text = document.createElement("span"); + text.textContent = chip.label + (chip.detail ? " (" + chip.detail + ")" : ""); + const remove = document.createElement("button"); + remove.textContent = "×"; + remove.title = "Remove"; + 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"; + 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.addEventListener("click", () => vscode.postMessage({ command: "decideApproval", id: approval.id, decision: "allow", remember: rememberBox.checked })); + const deny = document.createElement("button"); + deny.textContent = "Deny"; + 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"; + 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.addEventListener("click", () => { + if (selected.has(option.label)) { selected.delete(option.label); btn.style.opacity = ""; } + else { selected.add(option.label); btn.style.opacity = "0.6"; } + }); + } 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…"; + 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.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; + 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); + if (view.kind === "file_change") { + const open = document.createElement("button"); + open.textContent = "Open file"; + open.style.margin = "4px 8px"; + const path = view.metadata && (view.metadata.path || view.metadata.file || view.metadata.file_path); + if (path) { + open.addEventListener("click", () => vscode.postMessage({ command: "openFile", path: String(path) })); + 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..23efab08db 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -1,67 +1,37 @@ 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(); + const token = config.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); context.subscriptions.push( + vscode.window.registerWebviewViewProvider(ChatView.viewType, 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 +43,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 +95,6 @@ export function activate(context: vscode.ExtensionContext): void { if (autoRefreshInFlight) { return; } - autoRefreshInFlight = true; try { await checkAndRefreshRuntime(false, false); @@ -122,17 +108,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,21 +128,24 @@ 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", () => { const config = readRuntimeConfig(); startRuntimeTerminal(config); @@ -167,24 +154,16 @@ export function activate(context: vscode.ExtensionContext): void { 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 +171,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..bb04bb00b7 100644 --- a/extensions/vscode/src/runtime.ts +++ b/extensions/vscode/src/runtime.ts @@ -1,36 +1,15 @@ -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; @@ -59,86 +38,6 @@ 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"); const args = [ @@ -164,134 +63,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..0a5a8c57c5 --- /dev/null +++ b/extensions/vscode/src/secrets.ts @@ -0,0 +1,49 @@ +import * as vscode from "vscode"; + +const SECRET_KEY = "codewhale.runtimeToken"; + +/** + * Resolve the runtime bearer token: SecretStorage first, then the legacy + * `codewhale.runtimeToken` setting. The setting is kept for compatibility + * but SecretStorage is where new tokens go, so tokens stop living in + * plaintext settings.json / workspace dotfiles. + */ +export async function resolveToken(context: vscode.ExtensionContext): Promise { + const stored = await context.secrets.get(SECRET_KEY); + if (stored && stored.trim().length > 0) { + return stored.trim(); + } + const setting = vscode.workspace + .getConfiguration("codewhale") + .get("runtimeToken", "") + .trim(); + if (setting) { + // Migrate a settings-based token into secret storage so it can be + // removed from the (possibly synced) settings file. + await context.secrets.store(SECRET_KEY, setting); + return setting; + } + return undefined; +} + +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/test/api.test.ts b/extensions/vscode/src/test/api.test.ts new file mode 100644 index 0000000000..9a0dc03f2e --- /dev/null +++ b/extensions/vscode/src/test/api.test.ts @@ -0,0 +1,287 @@ +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, + 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; + } + response.writeHead(202, { "Content-Type": "application/json" }); + response.end( + JSON.stringify({ + thread: { id: "thr_1" }, + turn: { id: "turn_2", status: "queued" }, + }), + ); + 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 202", 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("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")); + await interruptTurn(config(), "thr_1", "turn_2"); + assert.ok(seen.path?.endsWith("/interrupt")); + }); + + 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 with server detail on conflict", 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); + assert.equal((error as ApiError).statusCode, 409); + 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("