| 1 | import * as crypto from "node:crypto"; |
| 2 | import * as path from "node:path"; |
| 3 | import * as vscode from "vscode"; |
| 4 | import { |
| 5 | answerUserInput, |
| 6 | checkConnection, |
| 7 | createThread, |
| 8 | getThreadDetail, |
| 9 | interruptTurn, |
| 10 | listThreadSummaries, |
| 11 | openEventStream, |
| 12 | resolveApproval, |
| 13 | startTurn, |
| 14 | steerTurn, |
| 15 | ApiConfig, |
| 16 | ConnectionInfo, |
| 17 | EventStream, |
| 18 | ItemRecord, |
| 19 | PendingApproval, |
| 20 | PendingUserInput, |
| 21 | ThreadDetail, |
| 22 | ThreadSummary, |
| 23 | type RuntimeEvent, |
| 24 | } from "./api"; |
| 25 | import { SseParser } from "./sse"; |
| 26 | import { |
| 27 | assemblePrompt, |
| 28 | collectActiveFileContext, |
| 29 | collectDiagnosticsContext, |
| 30 | collectSelectionContext, |
| 31 | type ContextChip, |
| 32 | } from "./context"; |
| 33 | import { |
| 34 | isInsideRoot, |
| 35 | projectItem, |
| 36 | statusForEvent, |
| 37 | type ItemView, |
| 38 | } from "./transcript"; |
| 39 | |
| 40 | /** |
| 41 | * Sidebar chat view: one active Codewhale thread at a time, streaming over |
| 42 | * the runtime's replayable SSE contract, with inline approvals, clarification |
| 43 | * questions, steer, and interrupt. State lives here; the webview only |
| 44 | * renders what it is told and posts intent back. |
| 45 | */ |
| 46 | |
| 47 | interface SyncMessage { |
| 48 | type: "sync"; |
| 49 | connection?: ConnectionInfo; |
| 50 | threads: ThreadSummary[]; |
| 51 | activeThreadId?: string; |
| 52 | model?: string; |
| 53 | streaming: boolean; |
| 54 | /** Interrupt asked for, turn not ended yet: keep Stop/Steer on screen. */ |
| 55 | interrupting: boolean; |
| 56 | chips: ContextChip[]; |
| 57 | approvals: PendingApproval[]; |
| 58 | inputs: PendingUserInput[]; |
| 59 | items: ItemView[]; |
| 60 | } |
| 61 | |
| 62 | type OutboundMessage = |
| 63 | | SyncMessage |
| 64 | | { type: "delta"; itemId: string; text: string } |
| 65 | | { type: "focusComposer" } |
| 66 | /** Tells the composer whether the send was accepted; it only clears on `ok`. */ |
| 67 | | { type: "composerResult"; ok: boolean }; |
| 68 | |
| 69 | export class ChatView implements vscode.WebviewViewProvider { |
| 70 | public static readonly viewType = "codewhale.chat"; |
| 71 | /** The secondary-sidebar twin. The same instance serves both ids. */ |
| 72 | public static readonly secondaryViewType = "codewhale.chatSecondary"; |
| 73 | |
| 74 | /** |
| 75 | * Which of the two view ids actually resolved. Only one is ever visible — |
| 76 | * the manifest gates them on `codewhale.noSecondarySidebar` — so `reveal()` |
| 77 | * must focus the one this host chose, not a hardcoded id. |
| 78 | */ |
| 79 | private resolvedViewType: string = ChatView.viewType; |
| 80 | |
| 81 | private view?: vscode.WebviewView; |
| 82 | private webviewReady = false; |
| 83 | private queued: OutboundMessage[] = []; |
| 84 | |
| 85 | private connection?: ConnectionInfo; |
| 86 | private threads: ThreadSummary[] = []; |
| 87 | private activeThreadId?: string; |
| 88 | private activeDetail?: ThreadDetail; |
| 89 | private items = new Map<string, ItemView>(); |
| 90 | private itemOrder: string[] = []; |
| 91 | private stream?: EventStream; |
| 92 | private lastSeq = 0; |
| 93 | private streamingTurnId?: string; |
| 94 | private interruptRequested = false; |
| 95 | /** |
| 96 | * The in-flight send. Its `operationKey` is reused when the same prompt is |
| 97 | * retried after a timeout so the runtime dedupes instead of starting a |
| 98 | * second turn. |
| 99 | */ |
| 100 | private pendingSend?: { threadId: string; prompt: string; operationKey: string }; |
| 101 | private reconnectAttempt = 0; |
| 102 | private reconnectTimer?: ReturnType<typeof setTimeout>; |
| 103 | private chips: ContextChip[] = []; |
| 104 | |
| 105 | constructor( |
| 106 | private readonly extensionContext: vscode.ExtensionContext, |
| 107 | private readonly configProvider: () => Promise<ApiConfig>, |
| 108 | private readonly output: vscode.OutputChannel, |
| 109 | ) {} |
| 110 | |
| 111 | resolveWebviewView(view: vscode.WebviewView): void { |
| 112 | this.view = view; |
| 113 | // Remember which id resolved so reveal() focuses the container this host |
| 114 | // actually shows. viewType is readonly on WebviewView and is one of the |
| 115 | // two ids registered in extension.ts. |
| 116 | this.resolvedViewType = view.viewType || ChatView.viewType; |
| 117 | view.webview.options = { enableScripts: true }; |
| 118 | view.onDidDispose(() => { |
| 119 | this.closeStream(); |
| 120 | this.view = undefined; |
| 121 | this.webviewReady = false; |
| 122 | }); |
| 123 | view.webview.onDidReceiveMessage((message: { command?: string; [key: string]: unknown }) => { |
| 124 | void this.handleWebviewMessage(message); |
| 125 | }); |
| 126 | view.webview.html = this.renderHtml(view); |
| 127 | } |
| 128 | |
| 129 | /** Show the chat sidebar and put focus in the composer. */ |
| 130 | async reveal(): Promise<void> { |
| 131 | await vscode.commands.executeCommand(`${this.resolvedViewType}.focus`); |
| 132 | } |
| 133 | |
| 134 | /** Connection updates pushed from the extension host. */ |
| 135 | setConnection(connection: ConnectionInfo): void { |
| 136 | this.connection = connection; |
| 137 | this.postSync(); |
| 138 | } |
| 139 | |
| 140 | async refreshThreads(): Promise<void> { |
| 141 | try { |
| 142 | this.threads = await listThreadSummaries(await this.configProvider()); |
| 143 | this.postSync(); |
| 144 | } catch (error) { |
| 145 | this.logError("Thread summaries unavailable", error); |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | /** "Ask Codewhale" entry: attach the current selection and focus the composer. */ |
| 150 | async askWithSelection(): Promise<void> { |
| 151 | const chip = collectSelectionContext() ?? collectActiveFileContext(); |
| 152 | if (chip && !this.chips.some((existing) => existing.label === chip.label)) { |
| 153 | this.chips.push(chip); |
| 154 | } |
| 155 | await this.reveal(); |
| 156 | this.post({ type: "focusComposer" }); |
| 157 | } |
| 158 | |
| 159 | addChip(kind: ContextChip["kind"]): void { |
| 160 | const chip = |
| 161 | kind === "selection" |
| 162 | ? collectSelectionContext() |
| 163 | : kind === "file" |
| 164 | ? collectActiveFileContext() |
| 165 | : collectDiagnosticsContext(); |
| 166 | if (!chip) { |
| 167 | void vscode.window.showInformationMessage("Nothing to attach for that context kind."); |
| 168 | return; |
| 169 | } |
| 170 | this.chips = this.chips.filter((existing) => existing.label !== chip.label); |
| 171 | this.chips.push(chip); |
| 172 | this.postSync(); |
| 173 | } |
| 174 | |
| 175 | removeChip(id: string): void { |
| 176 | this.chips = this.chips.filter((chip) => chip.id !== id); |
| 177 | this.postSync(); |
| 178 | } |
| 179 | |
| 180 | async newThread(): Promise<void> { |
| 181 | try { |
| 182 | const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; |
| 183 | const thread = await createThread(await this.configProvider(), workspace ? { workspace } : {}); |
| 184 | this.output.appendLine(`Created thread ${thread.id}`); |
| 185 | await this.selectThread(thread.id); |
| 186 | await this.refreshThreads(); |
| 187 | } catch (error) { |
| 188 | this.logError("Create thread failed", error); |
| 189 | void vscode.window.showErrorMessage(errorMessage(error)); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | async selectThread(threadId: string): Promise<void> { |
| 194 | this.closeStream(); |
| 195 | this.streamingTurnId = undefined; |
| 196 | this.interruptRequested = false; |
| 197 | this.items.clear(); |
| 198 | this.itemOrder = []; |
| 199 | this.activeThreadId = threadId; |
| 200 | this.postSync(); |
| 201 | try { |
| 202 | const detail = await getThreadDetail(await this.configProvider(), threadId); |
| 203 | if (this.activeThreadId !== threadId) { |
| 204 | return; // user switched away while loading |
| 205 | } |
| 206 | this.activeDetail = detail; |
| 207 | this.lastSeq = detail.latestSeq; |
| 208 | for (const item of detail.items) { |
| 209 | this.ingestItem(item); |
| 210 | } |
| 211 | this.openStream(threadId, detail.latestSeq).catch((error) => this.logError("Stream failed", error)); |
| 212 | this.postSync(); |
| 213 | this.scheduleThreadListRefresh(); |
| 214 | } catch (error) { |
| 215 | this.logError("Load thread failed", error); |
| 216 | void vscode.window.showErrorMessage(errorMessage(error)); |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // ---- webview -> extension ---- |
| 221 | |
| 222 | private async handleWebviewMessage(message: { command?: string; [key: string]: unknown }): Promise<void> { |
| 223 | switch (message.command) { |
| 224 | case "ready": |
| 225 | this.webviewReady = true; |
| 226 | for (const queued of this.queued.splice(0)) { |
| 227 | void this.view?.webview.postMessage(queued); |
| 228 | } |
| 229 | this.postSync(); |
| 230 | break; |
| 231 | case "check": |
| 232 | await vscode.commands.executeCommand("codewhale.checkRuntime"); |
| 233 | break; |
| 234 | case "start": |
| 235 | await vscode.commands.executeCommand("codewhale.startRuntime"); |
| 236 | break; |
| 237 | case "terminal": |
| 238 | await vscode.commands.executeCommand("codewhale.openTerminal"); |
| 239 | break; |
| 240 | case "setToken": |
| 241 | await vscode.commands.executeCommand("codewhale.setRuntimeToken"); |
| 242 | break; |
| 243 | case "newThread": |
| 244 | await this.newThread(); |
| 245 | break; |
| 246 | case "selectThread": |
| 247 | await this.selectThread(String(message.id ?? "")); |
| 248 | break; |
| 249 | case "refreshThreads": |
| 250 | await this.refreshThreads(); |
| 251 | break; |
| 252 | case "sendPrompt": |
| 253 | await this.sendPrompt(String(message.text ?? "")); |
| 254 | break; |
| 255 | case "steer": |
| 256 | await this.steer(String(message.text ?? "")); |
| 257 | break; |
| 258 | case "interrupt": |
| 259 | await this.interrupt(); |
| 260 | break; |
| 261 | case "decideApproval": |
| 262 | await this.decideApproval( |
| 263 | String(message.id ?? ""), |
| 264 | message.decision === "allow" ? "allow" : "deny", |
| 265 | message.remember === true, |
| 266 | ); |
| 267 | break; |
| 268 | case "answerInput": |
| 269 | await this.answerInput(message); |
| 270 | break; |
| 271 | case "addChip": |
| 272 | this.addChip(message.kind === "file" ? "file" : message.kind === "diagnostics" ? "diagnostics" : "selection"); |
| 273 | break; |
| 274 | case "removeChip": |
| 275 | this.removeChip(String(message.id ?? "")); |
| 276 | break; |
| 277 | case "copyCode": |
| 278 | await vscode.env.clipboard.writeText(String(message.code ?? "")); |
| 279 | break; |
| 280 | case "insertCode": |
| 281 | await this.insertAtCursor(String(message.code ?? "")); |
| 282 | break; |
| 283 | case "openFile": |
| 284 | await this.openFileAtPath(String(message.path ?? "")); |
| 285 | break; |
| 286 | case "openLink": { |
| 287 | const url = String(message.url ?? ""); |
| 288 | if (/^https?:\/\//.test(url)) { |
| 289 | void vscode.env.openExternal(vscode.Uri.parse(url)); |
| 290 | } |
| 291 | break; |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | private async sendPrompt(text: string): Promise<void> { |
| 297 | const prompt = text.trim(); |
| 298 | if (!prompt) { |
| 299 | this.post({ type: "composerResult", ok: false }); |
| 300 | return; |
| 301 | } |
| 302 | if (!this.activeThreadId) { |
| 303 | await this.newThread(); |
| 304 | if (!this.activeThreadId) { |
| 305 | this.post({ type: "composerResult", ok: false }); |
| 306 | return; |
| 307 | } |
| 308 | } |
| 309 | const threadId = this.activeThreadId; |
| 310 | const assembled = assemblePrompt(prompt, this.chips); |
| 311 | // A resend of the same pending prompt must carry the same operation key, |
| 312 | // or a timeout-then-retry lands as two turns. |
| 313 | const operationKey = |
| 314 | this.pendingSend && this.pendingSend.threadId === threadId && this.pendingSend.prompt === assembled |
| 315 | ? this.pendingSend.operationKey |
| 316 | : crypto.randomUUID(); |
| 317 | this.pendingSend = { threadId, prompt: assembled, operationKey }; |
| 318 | try { |
| 319 | const result = await startTurn(await this.configProvider(), threadId, { |
| 320 | prompt: assembled, |
| 321 | operationKey, |
| 322 | }); |
| 323 | // Accepted: only now is it safe to drop the composer text and chips. |
| 324 | this.pendingSend = undefined; |
| 325 | this.chips = []; |
| 326 | this.streamingTurnId = result.turn.id; |
| 327 | this.interruptRequested = false; |
| 328 | this.addLocalUserMessage(prompt); |
| 329 | this.post({ type: "composerResult", ok: true }); |
| 330 | void this.openStream(threadId, this.lastSeq); |
| 331 | this.postSync(); |
| 332 | } catch (error) { |
| 333 | // Keep `pendingSend` so the retry reuses the key, and give the text back. |
| 334 | this.post({ type: "composerResult", ok: false }); |
| 335 | this.handleError("Send failed", error); |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | private async steer(text: string): Promise<void> { |
| 340 | if (!this.activeThreadId || !this.streamingTurnId) { |
| 341 | return; |
| 342 | } |
| 343 | try { |
| 344 | await steerTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId, text.trim()); |
| 345 | this.addLocalUserMessage(`[steer] ${text.trim()}`); |
| 346 | } catch (error) { |
| 347 | this.handleError("Steer failed", error); |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | private async interrupt(): Promise<void> { |
| 352 | if (!this.activeThreadId || !this.streamingTurnId) { |
| 353 | return; |
| 354 | } |
| 355 | try { |
| 356 | await interruptTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId); |
| 357 | this.output.appendLine(`Interrupt requested for turn ${this.streamingTurnId}`); |
| 358 | } catch (error) { |
| 359 | this.handleError("Interrupt failed", error); |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | private async decideApproval(id: string, decision: "allow" | "deny", remember: boolean): Promise<void> { |
| 364 | try { |
| 365 | await resolveApproval(await this.configProvider(), id, decision, remember); |
| 366 | } catch (error) { |
| 367 | this.handleError("Approval failed", error); |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | private async answerInput(message: { [key: string]: unknown }): Promise<void> { |
| 372 | if (!this.activeThreadId) { |
| 373 | return; |
| 374 | } |
| 375 | const raw = Array.isArray(message.answers) ? message.answers : []; |
| 376 | const answers = raw.flatMap((entry) => { |
| 377 | if (!entry || typeof entry !== "object") { |
| 378 | return []; |
| 379 | } |
| 380 | const record = entry as Record<string, unknown>; |
| 381 | const id = typeof record.id === "string" ? record.id : undefined; |
| 382 | const label = typeof record.label === "string" ? record.label : undefined; |
| 383 | if (!id || !label) { |
| 384 | return []; |
| 385 | } |
| 386 | return [{ id, label, value: typeof record.value === "string" ? record.value : label }]; |
| 387 | }); |
| 388 | if (answers.length === 0) { |
| 389 | return; |
| 390 | } |
| 391 | try { |
| 392 | await answerUserInput(await this.configProvider(), this.activeThreadId, String(message.inputId ?? ""), answers); |
| 393 | } catch (error) { |
| 394 | this.handleError("Answer failed", error); |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | private async insertAtCursor(code: string): Promise<void> { |
| 399 | const editor = vscode.window.activeTextEditor; |
| 400 | if (!editor) { |
| 401 | void vscode.window.showInformationMessage("Open a file to insert code."); |
| 402 | return; |
| 403 | } |
| 404 | await editor.edit((builder) => builder.replace(editor.selection, code)); |
| 405 | void vscode.window.showTextDocument(editor.document); |
| 406 | } |
| 407 | |
| 408 | /** |
| 409 | * Open a path that came from an item's tool metadata. That value is |
| 410 | * model-influenced, so it is treated as untrusted: workspace-relative |
| 411 | * resolution is preferred, `..` escapes are refused outright, and anything |
| 412 | * outside the workspace needs an explicit confirmation from the user. |
| 413 | */ |
| 414 | private async openFileAtPath(raw: string): Promise<void> { |
| 415 | const candidate = raw.trim(); |
| 416 | if (!candidate || /[\u0000-\u001f\u007f]/.test(candidate)) { |
| 417 | return; |
| 418 | } |
| 419 | const folders = vscode.workspace.workspaceFolders ?? []; |
| 420 | const targets: vscode.Uri[] = []; |
| 421 | |
| 422 | if (path.isAbsolute(candidate)) { |
| 423 | const resolved = path.resolve(candidate); |
| 424 | const inWorkspace = folders.some((folder) => isInsideRoot(folder.uri.fsPath, resolved)); |
| 425 | if (!inWorkspace) { |
| 426 | const choice = await vscode.window.showWarningMessage( |
| 427 | `Open a file outside this workspace?\n\n${resolved}`, |
| 428 | { modal: true }, |
| 429 | "Open File", |
| 430 | ); |
| 431 | if (choice !== "Open File") { |
| 432 | return; |
| 433 | } |
| 434 | } |
| 435 | targets.push(vscode.Uri.file(resolved)); |
| 436 | } else { |
| 437 | for (const folder of folders) { |
| 438 | // joinPath keeps the folder's scheme (remote/virtual workspaces); the |
| 439 | // containment check runs on the resolved filesystem path. |
| 440 | if (isInsideRoot(folder.uri.fsPath, candidate)) { |
| 441 | targets.push(vscode.Uri.joinPath(folder.uri, candidate)); |
| 442 | } |
| 443 | } |
| 444 | if (targets.length === 0) { |
| 445 | void vscode.window.showWarningMessage(`Refused to open a path outside the workspace: ${candidate}`); |
| 446 | return; |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | for (const uri of targets) { |
| 451 | try { |
| 452 | await vscode.workspace.fs.stat(uri); |
| 453 | await vscode.window.showTextDocument(uri, { preview: true }); |
| 454 | return; |
| 455 | } catch { |
| 456 | // try the next candidate |
| 457 | } |
| 458 | } |
| 459 | void vscode.window.showInformationMessage(`File not found: ${candidate}`); |
| 460 | } |
| 461 | |
| 462 | // ---- SSE event ingestion ---- |
| 463 | |
| 464 | private async openStream(threadId: string, sinceSeq: number): Promise<void> { |
| 465 | this.closeStream(); |
| 466 | this.reconnectAttempt = 0; |
| 467 | const config = await this.configProvider(); |
| 468 | if (this.activeThreadId !== threadId) { |
| 469 | return; |
| 470 | } |
| 471 | const stream = openEventStream(config, threadId, sinceSeq, new SseParser()); |
| 472 | stream.onEvent = (event) => this.handleStreamEvent(event); |
| 473 | stream.onError = (error) => this.handleStreamError(threadId, error); |
| 474 | this.stream = stream; |
| 475 | } |
| 476 | |
| 477 | private closeStream(): void { |
| 478 | if (this.reconnectTimer) { |
| 479 | clearTimeout(this.reconnectTimer); |
| 480 | this.reconnectTimer = undefined; |
| 481 | } |
| 482 | this.stream?.close(); |
| 483 | this.stream = undefined; |
| 484 | } |
| 485 | |
| 486 | private handleStreamEvent(event: RuntimeEvent): void { |
| 487 | if (event.seq <= this.lastSeq) { |
| 488 | return; // duplicate or stale replay |
| 489 | } |
| 490 | this.lastSeq = event.seq; |
| 491 | this.reconnectAttempt = 0; |
| 492 | |
| 493 | switch (event.event) { |
| 494 | case "item.started": |
| 495 | case "item.completed": |
| 496 | case "item.failed": |
| 497 | case "item.interrupted": |
| 498 | case "item.canceled": { |
| 499 | const payloadItem = readPayloadItem(event.payload); |
| 500 | const itemId = event.itemId ?? payloadItem?.id; |
| 501 | if (!itemId) { |
| 502 | return; |
| 503 | } |
| 504 | const existing = this.items.get(itemId); |
| 505 | const merged: ItemRecord = { |
| 506 | id: itemId, |
| 507 | turnId: event.turnId ?? existing?.turnId, |
| 508 | kind: payloadItem?.kind ?? existing?.kind ?? "status", |
| 509 | status: payloadItem?.status ?? statusForEvent(event.event), |
| 510 | summary: payloadItem?.summary ?? existing?.summary ?? "", |
| 511 | detail: payloadItem?.detail ?? existing?.detail, |
| 512 | metadata: payloadItem?.metadata ?? existing?.metadata, |
| 513 | }; |
| 514 | this.ingestItem(merged, event.event); |
| 515 | this.postSync(); |
| 516 | break; |
| 517 | } |
| 518 | case "item.delta": { |
| 519 | const delta = readPayloadDelta(event.payload); |
| 520 | if (!delta || !event.itemId) { |
| 521 | return; |
| 522 | } |
| 523 | const view = this.items.get(event.itemId); |
| 524 | if (view) { |
| 525 | view.streamText = (view.streamText ?? "") + delta; |
| 526 | view.rev += 1; |
| 527 | } else { |
| 528 | this.ingestItem({ |
| 529 | id: event.itemId, |
| 530 | turnId: event.turnId, |
| 531 | kind: readPayloadKind(event.payload) ?? "agent_message", |
| 532 | summary: delta, |
| 533 | }); |
| 534 | } |
| 535 | this.post({ type: "delta", itemId: event.itemId, text: delta }); |
| 536 | break; |
| 537 | } |
| 538 | case "approval.required": { |
| 539 | const approval = readPayloadApproval(event.payload); |
| 540 | if (approval && this.activeDetail) { |
| 541 | this.activeDetail.pendingApprovals = [ |
| 542 | ...this.activeDetail.pendingApprovals.filter((entry) => entry.id !== approval.id), |
| 543 | approval, |
| 544 | ]; |
| 545 | this.postSync(); |
| 546 | } |
| 547 | break; |
| 548 | } |
| 549 | case "approval.decided": |
| 550 | case "approval.timeout": { |
| 551 | const id = readPayloadId(event.payload) ?? event.itemId; |
| 552 | if (id && this.activeDetail) { |
| 553 | this.activeDetail.pendingApprovals = this.activeDetail.pendingApprovals.filter( |
| 554 | (entry) => entry.id !== id, |
| 555 | ); |
| 556 | this.postSync(); |
| 557 | } |
| 558 | break; |
| 559 | } |
| 560 | case "user_input.required": { |
| 561 | const input = readPayloadUserInput(event.payload); |
| 562 | if (input && this.activeDetail) { |
| 563 | this.activeDetail.pendingUserInputs = [ |
| 564 | ...this.activeDetail.pendingUserInputs.filter((entry) => entry.id !== input.id), |
| 565 | input, |
| 566 | ]; |
| 567 | this.postSync(); |
| 568 | } |
| 569 | break; |
| 570 | } |
| 571 | case "user_input.answered": |
| 572 | case "user_input.canceled": { |
| 573 | const id = readPayloadId(event.payload); |
| 574 | if (id && this.activeDetail) { |
| 575 | this.activeDetail.pendingUserInputs = this.activeDetail.pendingUserInputs.filter( |
| 576 | (entry) => entry.id !== id, |
| 577 | ); |
| 578 | this.postSync(); |
| 579 | } |
| 580 | break; |
| 581 | } |
| 582 | case "turn.interrupt_requested": { |
| 583 | // The turn is still running until it reports an end state; keep the |
| 584 | // Stop/Steer controls on screen instead of hiding them here. |
| 585 | if (event.turnId && event.turnId === this.streamingTurnId) { |
| 586 | this.interruptRequested = true; |
| 587 | this.postSync(); |
| 588 | } |
| 589 | break; |
| 590 | } |
| 591 | case "turn.completed": |
| 592 | case "turn.failed": |
| 593 | case "turn.interrupted": |
| 594 | case "turn.ended": { |
| 595 | if (event.turnId && event.turnId === this.streamingTurnId) { |
| 596 | this.streamingTurnId = undefined; |
| 597 | this.interruptRequested = false; |
| 598 | this.postSync(); |
| 599 | this.scheduleThreadListRefresh(); |
| 600 | } |
| 601 | break; |
| 602 | } |
| 603 | default: |
| 604 | break; |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | private handleStreamError(threadId: string, error: Error): void { |
| 609 | if (this.activeThreadId !== threadId) { |
| 610 | return; |
| 611 | } |
| 612 | if (error instanceof Error && "statusCode" in error && (error as { statusCode?: number }).statusCode === 401) { |
| 613 | this.connection = { kind: "auth-required", detail: "Runtime token was rejected." }; |
| 614 | this.postSync(); |
| 615 | return; |
| 616 | } |
| 617 | // The stream is dead; drop it, or the `!this.stream` guard below never |
| 618 | // passes and the reconnect silently never happens. |
| 619 | this.stream?.close(); |
| 620 | this.stream = undefined; |
| 621 | const attempt = ++this.reconnectAttempt; |
| 622 | const delay = Math.min(1000 * attempt, 5000); |
| 623 | this.output.appendLine(`Event stream for ${threadId} dropped (${error.message}); retrying in ${delay}ms`); |
| 624 | this.reconnectTimer = setTimeout(() => { |
| 625 | this.reconnectTimer = undefined; |
| 626 | if (this.activeThreadId === threadId && !this.stream) { |
| 627 | void this.openStream(threadId, this.lastSeq).catch((error) => |
| 628 | this.logError("Stream reconnect failed", error), |
| 629 | ); |
| 630 | } |
| 631 | }, delay); |
| 632 | } |
| 633 | |
| 634 | /** Fill the item projection from a thread-detail snapshot or SSE event. */ |
| 635 | private ingestItem(item: ItemRecord, event?: string): void { |
| 636 | const existing = this.items.get(item.id); |
| 637 | this.items.set(item.id, projectItem(item, existing, event)); |
| 638 | if (!existing) { |
| 639 | this.itemOrder.push(item.id); |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | private addLocalUserMessage(text: string): void { |
| 644 | const id = `local-${crypto.randomUUID()}`; |
| 645 | this.items.set(id, { id, kind: "user_message", summary: text, rev: 1 }); |
| 646 | this.itemOrder.push(id); |
| 647 | this.postSync(); |
| 648 | } |
| 649 | |
| 650 | private scheduleThreadListRefresh(): void { |
| 651 | // Titles/previews settle right after a turn finishes; refresh lazily. |
| 652 | setTimeout(() => { |
| 653 | void this.refreshThreads(); |
| 654 | }, 1200); |
| 655 | } |
| 656 | |
| 657 | // ---- outbound ---- |
| 658 | |
| 659 | private post(message: OutboundMessage): void { |
| 660 | if (!this.view) { |
| 661 | return; |
| 662 | } |
| 663 | if (!this.webviewReady) { |
| 664 | this.queued.push(message); |
| 665 | return; |
| 666 | } |
| 667 | void this.view.webview.postMessage(message); |
| 668 | } |
| 669 | |
| 670 | private postSync(): void { |
| 671 | this.post({ |
| 672 | type: "sync", |
| 673 | connection: this.connection, |
| 674 | threads: this.threads, |
| 675 | activeThreadId: this.activeThreadId, |
| 676 | model: this.activeDetail?.thread.model ?? this.threads.find((t) => t.id === this.activeThreadId)?.model, |
| 677 | streaming: this.streamingTurnId !== undefined, |
| 678 | interrupting: this.interruptRequested, |
| 679 | chips: this.chips, |
| 680 | approvals: this.activeDetail?.pendingApprovals ?? [], |
| 681 | inputs: this.activeDetail?.pendingUserInputs ?? [], |
| 682 | items: this.itemOrder.flatMap((id) => { |
| 683 | const view = this.items.get(id); |
| 684 | return view ? [view] : []; |
| 685 | }), |
| 686 | }); |
| 687 | } |
| 688 | |
| 689 | private handleError(where: string, error: unknown): void { |
| 690 | this.logError(where, error); |
| 691 | if (isAuthError(error)) { |
| 692 | this.connection = { kind: "auth-required", detail: "Runtime token was rejected." }; |
| 693 | this.postSync(); |
| 694 | } |
| 695 | void vscode.window.showErrorMessage(`CodeWhale ${where.toLowerCase()}: ${errorMessage(error)}`); |
| 696 | } |
| 697 | |
| 698 | private logError(where: string, error: unknown): void { |
| 699 | this.output.appendLine(`${new Date().toISOString()} ${where}: ${errorMessage(error)}`); |
| 700 | } |
| 701 | |
| 702 | /** Load the thread list + latest detail for the active thread (used after reconnects). */ |
| 703 | async resyncAfterConnection(): Promise<void> { |
| 704 | await this.refreshThreads(); |
| 705 | if (this.activeThreadId) { |
| 706 | await this.selectThread(this.activeThreadId); |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | // ---- webview HTML ---- |
| 711 | |
| 712 | private renderHtml(view: vscode.WebviewView): string { |
| 713 | const nonce = makeNonce(); |
| 714 | return `<!doctype html> |
| 715 | <html lang="en"> |
| 716 | <head> |
| 717 | <meta charset="UTF-8"> |
| 718 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';"> |
| 719 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 720 | <style> |
| 721 | ${chatStyles()} |
| 722 | </style> |
| 723 | </head> |
| 724 | <body> |
| 725 | <header id="conn"> |
| 726 | <span id="conn-dot" class="dot offline" aria-hidden="true"></span> |
| 727 | <span id="conn-label" role="status" aria-live="polite">Checking runtime…</span> |
| 728 | <span class="spacer"></span> |
| 729 | <button id="btn-new" class="icon" title="New thread" aria-label="New thread">+ New</button> |
| 730 | </header> |
| 731 | <div id="conn-actions" class="hidden" role="group" aria-label="Runtime connection actions"> |
| 732 | <button id="btn-start">Start Local Runtime</button> |
| 733 | <button id="btn-token">Set Runtime Token</button> |
| 734 | <button id="btn-terminal">Open Terminal</button> |
| 735 | </div> |
| 736 | <details id="threads-box"> |
| 737 | <summary>Threads <span id="threads-count"></span></summary> |
| 738 | <div id="threads" role="group" aria-label="Threads"></div> |
| 739 | </details> |
| 740 | <main id="transcript" role="log" aria-live="polite" aria-relevant="additions text" |
| 741 | aria-label="Conversation transcript" tabindex="0"></main> |
| 742 | <div id="attention" role="region" aria-live="assertive" aria-label="Needs your attention"></div> |
| 743 | <div id="chips" role="list" aria-label="Attached context"></div> |
| 744 | <div id="steer-box" class="hidden" role="group" aria-label="Running turn controls"> |
| 745 | <label class="sr-only" for="steer-input">Steer the running turn</label> |
| 746 | <input id="steer-input" type="text" placeholder="Steer the running turn…" |
| 747 | aria-label="Steer the running turn" /> |
| 748 | <button id="btn-steer">Steer</button> |
| 749 | <button id="btn-interrupt" class="danger">Stop</button> |
| 750 | </div> |
| 751 | <footer id="composer"> |
| 752 | <div class="attach-row" role="group" aria-label="Attach editor context"> |
| 753 | <button id="btn-chip-selection" title="Attach current selection" aria-label="Attach current selection">+ Selection</button> |
| 754 | <button id="btn-chip-file" title="Attach active file" aria-label="Attach active file">+ File</button> |
| 755 | <button id="btn-chip-diagnostics" title="Attach problems" aria-label="Attach problems from the active file">+ Problems</button> |
| 756 | <span id="model-label" class="model" role="status" aria-live="polite"></span> |
| 757 | </div> |
| 758 | <label class="sr-only" for="prompt">Message Codewhale</label> |
| 759 | <textarea id="prompt" rows="3" aria-label="Message Codewhale" |
| 760 | aria-describedby="prompt-hint" |
| 761 | placeholder="Ask Codewhale… (Enter to send, Shift+Enter for newline)"></textarea> |
| 762 | <span id="prompt-hint" class="sr-only">Press Enter to send, Shift plus Enter for a new line.</span> |
| 763 | </footer> |
| 764 | <script nonce="${nonce}"> |
| 765 | ${chatScript()} |
| 766 | </script> |
| 767 | </body> |
| 768 | </html>`; |
| 769 | } |
| 770 | } |
| 771 | |
| 772 | function readPayloadItem(payload: unknown): Partial<ItemRecord> | undefined { |
| 773 | if (!payload || typeof payload !== "object") { |
| 774 | return undefined; |
| 775 | } |
| 776 | const record = payload as Record<string, unknown>; |
| 777 | const source = |
| 778 | record.item && typeof record.item === "object" ? (record.item as Record<string, unknown>) : record; |
| 779 | const summary = typeof source.summary === "string" ? source.summary : undefined; |
| 780 | return { |
| 781 | id: typeof source.id === "string" ? source.id : undefined, |
| 782 | kind: typeof source.kind === "string" ? source.kind : undefined, |
| 783 | status: typeof source.status === "string" ? source.status : undefined, |
| 784 | summary, |
| 785 | detail: typeof source.detail === "string" ? source.detail : undefined, |
| 786 | metadata: |
| 787 | source.metadata && typeof source.metadata === "object" |
| 788 | ? (source.metadata as Record<string, unknown>) |
| 789 | : undefined, |
| 790 | }; |
| 791 | } |
| 792 | |
| 793 | function readPayloadDelta(payload: unknown): string | undefined { |
| 794 | if (!payload || typeof payload !== "object") { |
| 795 | return undefined; |
| 796 | } |
| 797 | const delta = (payload as Record<string, unknown>).delta; |
| 798 | return typeof delta === "string" ? delta : undefined; |
| 799 | } |
| 800 | |
| 801 | function readPayloadKind(payload: unknown): string | undefined { |
| 802 | if (!payload || typeof payload !== "object") { |
| 803 | return undefined; |
| 804 | } |
| 805 | const kind = (payload as Record<string, unknown>).kind; |
| 806 | return typeof kind === "string" ? kind : undefined; |
| 807 | } |
| 808 | |
| 809 | function readPayloadId(payload: unknown): string | undefined { |
| 810 | if (!payload || typeof payload !== "object") { |
| 811 | return undefined; |
| 812 | } |
| 813 | const record = payload as Record<string, unknown>; |
| 814 | for (const key of ["approval_id", "input_id", "id"]) { |
| 815 | const value = record[key]; |
| 816 | if (typeof value === "string") { |
| 817 | return value; |
| 818 | } |
| 819 | } |
| 820 | return undefined; |
| 821 | } |
| 822 | |
| 823 | function readPayloadApproval(payload: unknown): PendingApproval | undefined { |
| 824 | if (!payload || typeof payload !== "object") { |
| 825 | return undefined; |
| 826 | } |
| 827 | const record = payload as Record<string, unknown>; |
| 828 | const id = readPayloadId(record); |
| 829 | if (!id) { |
| 830 | return undefined; |
| 831 | } |
| 832 | return { |
| 833 | id, |
| 834 | turnId: typeof record.turn_id === "string" ? record.turn_id : undefined, |
| 835 | toolName: typeof record.tool_name === "string" ? record.tool_name : "tool", |
| 836 | description: typeof record.description === "string" ? record.description : "", |
| 837 | intentSummary: typeof record.intent_summary === "string" ? record.intent_summary : undefined, |
| 838 | }; |
| 839 | } |
| 840 | |
| 841 | function readPayloadUserInput(payload: unknown): PendingUserInput | undefined { |
| 842 | if (!payload || typeof payload !== "object") { |
| 843 | return undefined; |
| 844 | } |
| 845 | const record = payload as Record<string, unknown>; |
| 846 | const id = readPayloadId(record); |
| 847 | if (!id) { |
| 848 | return undefined; |
| 849 | } |
| 850 | const request = |
| 851 | record.request && typeof record.request === "object" |
| 852 | ? (record.request as Record<string, unknown>) |
| 853 | : record; |
| 854 | const questions = Array.isArray(request.questions) |
| 855 | ? request.questions.flatMap((raw) => { |
| 856 | if (!raw || typeof raw !== "object") { |
| 857 | return []; |
| 858 | } |
| 859 | const question = raw as Record<string, unknown>; |
| 860 | const questionId = typeof question.id === "string" ? question.id : undefined; |
| 861 | if (!questionId) { |
| 862 | return []; |
| 863 | } |
| 864 | return [ |
| 865 | { |
| 866 | id: questionId, |
| 867 | header: typeof question.header === "string" ? question.header : undefined, |
| 868 | question: typeof question.question === "string" ? question.question : "", |
| 869 | allowFreeText: question.allow_free_text === true, |
| 870 | multiSelect: question.multi_select === true, |
| 871 | options: Array.isArray(question.options) |
| 872 | ? question.options.flatMap((optionRaw) => { |
| 873 | if (!optionRaw || typeof optionRaw !== "object") { |
| 874 | return []; |
| 875 | } |
| 876 | const option = optionRaw as Record<string, unknown>; |
| 877 | return typeof option.label === "string" |
| 878 | ? [ |
| 879 | { |
| 880 | label: option.label, |
| 881 | description: |
| 882 | typeof option.description === "string" ? option.description : undefined, |
| 883 | }, |
| 884 | ] |
| 885 | : []; |
| 886 | }) |
| 887 | : [], |
| 888 | }, |
| 889 | ]; |
| 890 | }) |
| 891 | : []; |
| 892 | return { id, turnId: typeof record.turn_id === "string" ? record.turn_id : undefined, questions }; |
| 893 | } |
| 894 | |
| 895 | function isAuthError(error: unknown): boolean { |
| 896 | return error instanceof Error && "statusCode" in error && (error as { statusCode?: number }).statusCode === 401; |
| 897 | } |
| 898 | |
| 899 | function errorMessage(error: unknown): string { |
| 900 | return error instanceof Error ? error.message : String(error); |
| 901 | } |
| 902 | |
| 903 | function makeNonce(): string { |
| 904 | return crypto.randomBytes(16).toString("hex"); |
| 905 | } |
| 906 | |
| 907 | function chatStyles(): string { |
| 908 | return ` |
| 909 | body { display: flex; flex-direction: column; height: 100vh; margin: 0; padding: 0; |
| 910 | color: var(--vscode-foreground); font-family: var(--vscode-font-family); font-size: var(--vscode-font-size, 13px); } |
| 911 | button { font-family: inherit; font-size: 11px; cursor: pointer; color: var(--vscode-button-foreground); |
| 912 | background: var(--vscode-button-secondaryBackground); border: none; border-radius: 3px; padding: 3px 8px; } |
| 913 | button.primary { background: var(--vscode-button-background); } |
| 914 | button.danger { background: var(--vscode-errorForeground); color: var(--vscode-editor-background); } |
| 915 | button:hover { filter: brightness(1.1); } |
| 916 | button[aria-disabled="true"] { opacity: 0.6; cursor: default; } |
| 917 | /* Keyboard focus must be visible everywhere it can land. */ |
| 918 | :focus-visible { outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: 1px; border-radius: 3px; } |
| 919 | button:focus-visible, summary:focus-visible, [tabindex]:focus-visible, .thread:focus-visible, .chip button:focus-visible { |
| 920 | outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: 1px; } |
| 921 | input[type="text"]:focus-visible, input[type="checkbox"]:focus-visible, textarea:focus-visible { |
| 922 | outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: -1px; |
| 923 | border-color: var(--vscode-focusBorder, #0078d4); } |
| 924 | /* Some hosts still report only :focus for the textarea; keep it obvious. */ |
| 925 | textarea:focus, input[type="text"]:focus { border-color: var(--vscode-focusBorder, #0078d4); } |
| 926 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; |
| 927 | clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } |
| 928 | input[type="text"], textarea { width: 100%; box-sizing: border-box; color: var(--vscode-input-foreground); |
| 929 | background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 3px; |
| 930 | padding: 6px 8px; font-family: inherit; font-size: inherit; resize: vertical; } |
| 931 | header { display: flex; align-items: center; gap: 6px; padding: 8px 10px; } |
| 932 | .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } |
| 933 | .dot.connected { background: var(--vscode-testing-iconPassed, #2ea043); } |
| 934 | .dot.offline { background: var(--vscode-testing-iconFailed, #f14c4c); } |
| 935 | .dot.auth-required { background: var(--vscode-editorWarning-foreground, #cca700); } |
| 936 | .dot.error { background: var(--vscode-testing-iconFailed, #f14c4c); } |
| 937 | .spacer { flex: 1; } |
| 938 | .hidden { display: none !important; } |
| 939 | #conn-label { color: var(--vscode-descriptionForeground); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 940 | #conn-actions { display: flex; gap: 6px; padding: 0 10px 8px; flex-wrap: wrap; } |
| 941 | details { border-top: 1px solid var(--vscode-panel-border, #333); } |
| 942 | #threads-box { padding: 0 10px; } |
| 943 | #threads-box summary { cursor: pointer; padding: 6px 0; color: var(--vscode-descriptionForeground); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; } |
| 944 | .thread { padding: 5px 6px; border-radius: 4px; cursor: pointer; overflow: hidden; } |
| 945 | .thread:hover { background: var(--vscode-list-hoverBackground); } |
| 946 | .thread.active { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } |
| 947 | .thread-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 948 | .thread-meta { color: var(--vscode-descriptionForeground); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 949 | main { flex: 1; overflow-y: auto; padding: 4px 10px; } |
| 950 | .msg { margin: 8px 0; } |
| 951 | .msg .who { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; |
| 952 | color: var(--vscode-descriptionForeground); margin-bottom: 2px; } |
| 953 | .msg.user .who { color: var(--vscode-textLink-foreground); } |
| 954 | .bubble { border-radius: 6px; padding: 6px 9px; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; } |
| 955 | .msg.user .bubble { background: var(--vscode-input-background); border: 1px solid var(--vscode-panel-border, #333); } |
| 956 | .msg.agent .bubble { white-space: normal; } |
| 957 | .msg.agent .bubble p { margin: 0 0 8px; } |
| 958 | .msg.agent .bubble p:last-child { margin-bottom: 0; } |
| 959 | .msg.agent .bubble h3, .msg.agent .bubble h4, .msg.agent .bubble h5 { margin: 10px 0 4px; } |
| 960 | .msg.agent .bubble ul, .msg.agent .bubble ol { margin: 4px 0; padding-left: 20px; } |
| 961 | .msg.agent .bubble hr { border: none; border-top: 1px solid var(--vscode-panel-border, #333); } |
| 962 | .msg.agent .bubble a { color: var(--vscode-textLink-foreground); } |
| 963 | .streaming::after { content: "▍"; animation: blink 1s steps(2) infinite; color: var(--vscode-descriptionForeground); } |
| 964 | @keyframes blink { 50% { opacity: 0; } } |
| 965 | .tool { margin: 6px 0; border: 1px solid var(--vscode-panel-border, #333); border-radius: 5px; font-size: 12px; } |
| 966 | .tool summary { cursor: pointer; padding: 5px 8px; color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 967 | .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; } |
| 968 | .msg.error .bubble { color: var(--vscode-errorForeground); } |
| 969 | .msg.status .bubble { color: var(--vscode-descriptionForeground); font-size: 11px; } |
| 970 | .codeblock { margin: 8px 0; border: 1px solid var(--vscode-panel-border, #333); border-radius: 5px; overflow: hidden; } |
| 971 | .codeblock-bar { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px 2px 8px; |
| 972 | background: var(--vscode-titleBar-activeBackground, #222); } |
| 973 | .codeblock-lang { font-size: 10px; color: var(--vscode-descriptionForeground); text-transform: uppercase; } |
| 974 | .codeblock-actions button { margin-left: 4px; padding: 1px 6px; font-size: 10px; } |
| 975 | .codeblock pre { margin: 0; padding: 8px; overflow-x: auto; font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; } |
| 976 | .card { margin: 8px 0; border: 1px solid var(--vscode-editorWarning-foreground, #cca700); border-radius: 6px; padding: 8px; } |
| 977 | .card .title { font-weight: 700; margin-bottom: 4px; } |
| 978 | .card .desc { color: var(--vscode-descriptionForeground); margin-bottom: 8px; overflow-wrap: anywhere; white-space: pre-wrap; } |
| 979 | .card .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 6px; } |
| 980 | .card label { font-size: 11px; color: var(--vscode-descriptionForeground); display: flex; gap: 4px; align-items: center; } |
| 981 | .opt { display: block; width: 100%; text-align: left; margin: 3px 0; padding: 5px 8px; } |
| 982 | .opt .opt-desc { display: block; font-weight: 400; color: var(--vscode-descriptionForeground); font-size: 10px; } |
| 983 | #chips { display: flex; gap: 4px; flex-wrap: wrap; padding: 0 10px 4px; } |
| 984 | .chip { display: inline-flex; gap: 4px; align-items: center; background: var(--vscode-badge-background); |
| 985 | color: var(--vscode-badge-foreground); border-radius: 8px; padding: 1px 8px; font-size: 10px; } |
| 986 | .chip button { background: transparent; color: inherit; padding: 0 2px; font-size: 11px; } |
| 987 | #steer-box { display: flex; gap: 6px; padding: 4px 10px; } |
| 988 | #steer-box input { flex: 1; } |
| 989 | #composer { border-top: 1px solid var(--vscode-panel-border, #333); padding: 6px 10px 10px; } |
| 990 | .attach-row { display: flex; gap: 4px; margin-bottom: 4px; align-items: center; } |
| 991 | .attach-row button { font-size: 10px; padding: 1px 6px; } |
| 992 | .model { margin-left: auto; color: var(--vscode-descriptionForeground); font-size: 10px; } |
| 993 | main:focus-visible { outline: 2px solid var(--vscode-focusBorder, #0078d4); outline-offset: -2px; } |
| 994 | #transcript .empty { color: var(--vscode-descriptionForeground); text-align: center; margin-top: 30px; line-height: 1.6; } |
| 995 | `; |
| 996 | } |
| 997 | |
| 998 | /** |
| 999 | * The webview script as a string. Kept here (not in a separate file) so the |
| 1000 | * extension stays a no-bundler build; it must never interpolate runtime data. |
| 1001 | */ |
| 1002 | function chatScript(): string { |
| 1003 | return ` |
| 1004 | const vscode = acquireVsCodeApi(); |
| 1005 | const transcript = document.getElementById("transcript"); |
| 1006 | const attention = document.getElementById("attention"); |
| 1007 | const chipsRow = document.getElementById("chips"); |
| 1008 | const threadsList = document.getElementById("threads"); |
| 1009 | const itemEls = new Map(); // item id -> element |
| 1010 | const codeBlocks = new Map(); // item id -> [raw code] |
| 1011 | const streamBufs = new Map(); // item id -> streaming text element |
| 1012 | let state = { streaming: false, activeThreadId: undefined }; |
| 1013 | |
| 1014 | document.getElementById("btn-new").addEventListener("click", () => vscode.postMessage({ command: "newThread" })); |
| 1015 | document.getElementById("btn-start").addEventListener("click", () => vscode.postMessage({ command: "start" })); |
| 1016 | document.getElementById("btn-token").addEventListener("click", () => vscode.postMessage({ command: "setToken" })); |
| 1017 | document.getElementById("btn-terminal").addEventListener("click", () => vscode.postMessage({ command: "terminal" })); |
| 1018 | document.getElementById("btn-chip-selection").addEventListener("click", () => vscode.postMessage({ command: "addChip", kind: "selection" })); |
| 1019 | document.getElementById("btn-chip-file").addEventListener("click", () => vscode.postMessage({ command: "addChip", kind: "file" })); |
| 1020 | document.getElementById("btn-chip-diagnostics").addEventListener("click", () => vscode.postMessage({ command: "addChip", kind: "diagnostics" })); |
| 1021 | document.getElementById("btn-interrupt").addEventListener("click", (e) => { |
| 1022 | if (e.currentTarget.getAttribute("aria-disabled") === "true") { return; } |
| 1023 | vscode.postMessage({ command: "interrupt" }); |
| 1024 | }); |
| 1025 | document.getElementById("btn-steer").addEventListener("click", sendSteer); |
| 1026 | document.getElementById("steer-input").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); sendSteer(); } }); |
| 1027 | |
| 1028 | const promptBox = document.getElementById("prompt"); |
| 1029 | promptBox.addEventListener("keydown", (e) => { |
| 1030 | if (e.key === "Enter" && !e.shiftKey) { |
| 1031 | e.preventDefault(); |
| 1032 | sendPrompt(); |
| 1033 | } |
| 1034 | }); |
| 1035 | |
| 1036 | let sending = false; |
| 1037 | let sentText = ""; |
| 1038 | function sendPrompt() { |
| 1039 | const text = promptBox.value.trim(); |
| 1040 | if (!text || sending) { return; } |
| 1041 | sentText = promptBox.value; |
| 1042 | // Do NOT clear here: the turn can still be refused and the text is the |
| 1043 | // user's. It is cleared only when the extension confirms acceptance. |
| 1044 | sending = true; |
| 1045 | promptBox.setAttribute("aria-busy", "true"); |
| 1046 | vscode.postMessage({ command: "sendPrompt", text }); |
| 1047 | } |
| 1048 | |
| 1049 | function composerResult(ok) { |
| 1050 | sending = false; |
| 1051 | promptBox.removeAttribute("aria-busy"); |
| 1052 | // Only clear what was actually sent: the user may have kept typing. |
| 1053 | if (ok && promptBox.value === sentText) { promptBox.value = ""; } |
| 1054 | sentText = ""; |
| 1055 | promptBox.focus(); |
| 1056 | } |
| 1057 | function sendSteer() { |
| 1058 | const box = document.getElementById("steer-input"); |
| 1059 | const text = box.value.trim(); |
| 1060 | if (!text) { return; } |
| 1061 | box.value = ""; |
| 1062 | vscode.postMessage({ command: "steer", text }); |
| 1063 | } |
| 1064 | |
| 1065 | window.addEventListener("message", (event) => { |
| 1066 | const msg = event.data; |
| 1067 | if (msg.type === "sync") { renderSync(msg); } |
| 1068 | else if (msg.type === "delta") { appendDelta(msg.itemId, msg.text); } |
| 1069 | else if (msg.type === "focusComposer") { promptBox.focus(); } |
| 1070 | else if (msg.type === "composerResult") { composerResult(msg.ok === true); } |
| 1071 | }); |
| 1072 | vscode.postMessage({ command: "ready" }); |
| 1073 | |
| 1074 | function renderSync(msg) { |
| 1075 | state = msg; |
| 1076 | renderConnection(msg); |
| 1077 | renderThreads(msg); |
| 1078 | renderChips(msg.chips || []); |
| 1079 | renderAttention(msg); |
| 1080 | renderItems(msg.items || []); |
| 1081 | const running = !!msg.streaming; |
| 1082 | document.getElementById("steer-box").classList.toggle("hidden", !running); |
| 1083 | const stop = document.getElementById("btn-interrupt"); |
| 1084 | stop.classList.toggle("hidden", !running); |
| 1085 | stop.textContent = msg.interrupting ? "Stopping…" : "Stop"; |
| 1086 | stop.setAttribute("aria-label", msg.interrupting ? "Stopping the current turn" : "Stop the current turn"); |
| 1087 | stop.setAttribute("aria-disabled", msg.interrupting ? "true" : "false"); |
| 1088 | document.getElementById("model-label").textContent = msg.model ? msg.model : ""; |
| 1089 | } |
| 1090 | |
| 1091 | function renderConnection(msg) { |
| 1092 | const conn = msg.connection; |
| 1093 | const dot = document.getElementById("conn-dot"); |
| 1094 | const label = document.getElementById("conn-label"); |
| 1095 | const actions = document.getElementById("conn-actions"); |
| 1096 | if (!conn) { dot.className = "dot offline"; label.textContent = "Checking runtime…"; actions.classList.add("hidden"); return; } |
| 1097 | dot.className = "dot " + conn.kind; |
| 1098 | label.textContent = conn.detail; |
| 1099 | label.title = conn.detail; |
| 1100 | const showActions = conn.kind !== "connected"; |
| 1101 | actions.classList.toggle("hidden", !showActions); |
| 1102 | document.getElementById("btn-token").classList.toggle("hidden", conn.kind !== "auth-required"); |
| 1103 | } |
| 1104 | |
| 1105 | function renderThreads(msg) { |
| 1106 | const threads = msg.threads || []; |
| 1107 | document.getElementById("threads-count").textContent = "(" + threads.length + ")"; |
| 1108 | threadsList.textContent = ""; |
| 1109 | for (const thread of threads) { |
| 1110 | const el = document.createElement("div"); |
| 1111 | el.className = "thread" + (thread.id === msg.activeThreadId ? " active" : ""); |
| 1112 | el.setAttribute("role", "button"); |
| 1113 | el.tabIndex = 0; |
| 1114 | if (thread.id === msg.activeThreadId) { el.setAttribute("aria-current", "true"); } |
| 1115 | const title = document.createElement("div"); |
| 1116 | title.className = "thread-title"; |
| 1117 | title.textContent = thread.title || "New Thread"; |
| 1118 | const meta = document.createElement("div"); |
| 1119 | meta.className = "thread-meta"; |
| 1120 | meta.textContent = [thread.model, thread.branch, thread.latestTurnStatus].filter(Boolean).join(" · "); |
| 1121 | el.appendChild(title); |
| 1122 | el.appendChild(meta); |
| 1123 | const open = () => vscode.postMessage({ command: "selectThread", id: thread.id }); |
| 1124 | el.setAttribute("aria-label", "Thread: " + (thread.title || "New Thread")); |
| 1125 | el.addEventListener("click", open); |
| 1126 | el.addEventListener("keydown", (e) => { |
| 1127 | if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } |
| 1128 | }); |
| 1129 | threadsList.appendChild(el); |
| 1130 | } |
| 1131 | } |
| 1132 | |
| 1133 | function renderChips(chips) { |
| 1134 | chipsRow.textContent = ""; |
| 1135 | for (const chip of chips) { |
| 1136 | const el = document.createElement("span"); |
| 1137 | el.className = "chip"; |
| 1138 | el.setAttribute("role", "listitem"); |
| 1139 | const text = document.createElement("span"); |
| 1140 | text.textContent = chip.label + (chip.detail ? " (" + chip.detail + ")" : ""); |
| 1141 | const remove = document.createElement("button"); |
| 1142 | remove.textContent = "×"; |
| 1143 | remove.title = "Remove"; |
| 1144 | remove.setAttribute("aria-label", "Remove context " + chip.label); |
| 1145 | remove.addEventListener("click", () => vscode.postMessage({ command: "removeChip", id: chip.id })); |
| 1146 | el.appendChild(text); |
| 1147 | el.appendChild(remove); |
| 1148 | chipsRow.appendChild(el); |
| 1149 | } |
| 1150 | } |
| 1151 | |
| 1152 | function renderAttention(msg) { |
| 1153 | attention.textContent = ""; |
| 1154 | for (const approval of msg.approvals || []) { |
| 1155 | attention.appendChild(approvalCard(approval)); |
| 1156 | } |
| 1157 | for (const input of msg.inputs || []) { |
| 1158 | attention.appendChild(userInputCard(msg.activeThreadId, input)); |
| 1159 | } |
| 1160 | } |
| 1161 | |
| 1162 | function approvalCard(approval) { |
| 1163 | const card = document.createElement("div"); |
| 1164 | card.className = "card"; |
| 1165 | card.setAttribute("role", "group"); |
| 1166 | card.setAttribute("aria-label", "Approval request for " + approval.toolName); |
| 1167 | const title = document.createElement("div"); |
| 1168 | title.className = "title"; |
| 1169 | title.textContent = "Approval: " + approval.toolName; |
| 1170 | const desc = document.createElement("div"); |
| 1171 | desc.className = "desc"; |
| 1172 | desc.textContent = approval.intentSummary ? approval.intentSummary + "\\n" + approval.description : approval.description; |
| 1173 | const row = document.createElement("div"); |
| 1174 | row.className = "row"; |
| 1175 | const remember = document.createElement("label"); |
| 1176 | const rememberBox = document.createElement("input"); |
| 1177 | rememberBox.type = "checkbox"; |
| 1178 | remember.appendChild(rememberBox); |
| 1179 | remember.appendChild(document.createTextNode(" remember")); |
| 1180 | const allow = document.createElement("button"); |
| 1181 | allow.className = "primary"; |
| 1182 | allow.textContent = "Allow"; |
| 1183 | allow.setAttribute("aria-label", "Allow " + approval.toolName); |
| 1184 | allow.addEventListener("click", () => vscode.postMessage({ command: "decideApproval", id: approval.id, decision: "allow", remember: rememberBox.checked })); |
| 1185 | const deny = document.createElement("button"); |
| 1186 | deny.textContent = "Deny"; |
| 1187 | deny.setAttribute("aria-label", "Deny " + approval.toolName); |
| 1188 | deny.addEventListener("click", () => vscode.postMessage({ command: "decideApproval", id: approval.id, decision: "deny", remember: rememberBox.checked })); |
| 1189 | row.appendChild(allow); row.appendChild(deny); row.appendChild(remember); |
| 1190 | card.appendChild(title); card.appendChild(desc); card.appendChild(row); |
| 1191 | return card; |
| 1192 | } |
| 1193 | |
| 1194 | function userInputCard(threadId, input) { |
| 1195 | const card = document.createElement("div"); |
| 1196 | card.className = "card"; |
| 1197 | card.setAttribute("role", "group"); |
| 1198 | card.setAttribute("aria-label", "Codewhale needs an answer"); |
| 1199 | for (const question of input.questions || []) { |
| 1200 | const title = document.createElement("div"); |
| 1201 | title.className = "title"; |
| 1202 | title.textContent = (question.header ? question.header + ": " : "") + question.question; |
| 1203 | card.appendChild(title); |
| 1204 | const selected = new Set(); |
| 1205 | const answer = (label) => vscode.postMessage({ |
| 1206 | command: "answerInput", inputId: input.id, |
| 1207 | answers: [{ id: question.id, label: label, value: label }], |
| 1208 | }); |
| 1209 | for (const option of question.options || []) { |
| 1210 | const btn = document.createElement("button"); |
| 1211 | btn.className = "opt"; |
| 1212 | btn.textContent = option.label; |
| 1213 | if (option.description) { |
| 1214 | const desc = document.createElement("span"); |
| 1215 | desc.className = "opt-desc"; |
| 1216 | desc.textContent = option.description; |
| 1217 | btn.appendChild(desc); |
| 1218 | } |
| 1219 | if (question.multiSelect) { |
| 1220 | btn.setAttribute("role", "checkbox"); |
| 1221 | btn.setAttribute("aria-checked", "false"); |
| 1222 | btn.addEventListener("click", () => { |
| 1223 | const on = !selected.has(option.label); |
| 1224 | if (on) { selected.add(option.label); btn.style.opacity = "0.6"; } |
| 1225 | else { selected.delete(option.label); btn.style.opacity = ""; } |
| 1226 | btn.setAttribute("aria-checked", on ? "true" : "false"); |
| 1227 | }); |
| 1228 | } else { |
| 1229 | btn.addEventListener("click", () => answer(option.label)); |
| 1230 | } |
| 1231 | card.appendChild(btn); |
| 1232 | } |
| 1233 | if (question.multiSelect && (question.options || []).length > 0) { |
| 1234 | const confirm = document.createElement("button"); |
| 1235 | confirm.className = "primary"; |
| 1236 | confirm.textContent = "Confirm"; |
| 1237 | confirm.addEventListener("click", () => vscode.postMessage({ |
| 1238 | command: "answerInput", inputId: input.id, |
| 1239 | answers: Array.from(selected).map((label) => ({ id: question.id, label: label, value: label })), |
| 1240 | })); |
| 1241 | card.appendChild(confirm); |
| 1242 | } |
| 1243 | if (question.allowFreeText) { |
| 1244 | const free = document.createElement("div"); |
| 1245 | free.className = "row"; |
| 1246 | const box = document.createElement("input"); |
| 1247 | box.type = "text"; |
| 1248 | box.placeholder = "Other…"; |
| 1249 | box.setAttribute("aria-label", "Other answer for: " + question.question); |
| 1250 | const send = document.createElement("button"); |
| 1251 | send.textContent = "Send"; |
| 1252 | send.addEventListener("click", () => { if (box.value.trim()) { answer(box.value.trim()); } }); |
| 1253 | free.appendChild(box); free.appendChild(send); |
| 1254 | card.appendChild(free); |
| 1255 | } |
| 1256 | } |
| 1257 | return card; |
| 1258 | } |
| 1259 | |
| 1260 | function renderItems(items) { |
| 1261 | const seen = new Set(); |
| 1262 | for (const view of items) { |
| 1263 | seen.add(view.id); |
| 1264 | const existing = itemEls.get(view.id); |
| 1265 | if (!existing) { |
| 1266 | itemEls.set(view.id, renderItem(view)); |
| 1267 | } else if (existing.dataset.rev !== String(view.rev)) { |
| 1268 | const fresh = renderItem(view); |
| 1269 | existing.replaceWith(fresh); |
| 1270 | itemEls.set(view.id, fresh); |
| 1271 | } |
| 1272 | } |
| 1273 | for (const [id, el] of Array.from(itemEls)) { |
| 1274 | if (!seen.has(id)) { el.remove(); itemEls.delete(id); streamBufs.delete(id); codeBlocks.delete(id); } |
| 1275 | } |
| 1276 | orderTranscript(items); |
| 1277 | trimEmpty(); |
| 1278 | scrollToBottom(); |
| 1279 | } |
| 1280 | |
| 1281 | function orderTranscript(items) { |
| 1282 | let cursor = transcript.firstChild; |
| 1283 | for (const view of items) { |
| 1284 | const el = itemEls.get(view.id); |
| 1285 | if (!el) { continue; } |
| 1286 | if (cursor === el) { cursor = el.nextSibling; continue; } |
| 1287 | transcript.insertBefore(el, cursor); |
| 1288 | } |
| 1289 | } |
| 1290 | |
| 1291 | function renderItem(view) { |
| 1292 | if (view.kind === "user_message") { |
| 1293 | return wrap("user", "You", plainBubble(view.summary)); |
| 1294 | } |
| 1295 | if (view.kind === "agent_message") { |
| 1296 | if (view.html !== undefined) { |
| 1297 | streamBufs.delete(view.id); |
| 1298 | const bubble = document.createElement("div"); |
| 1299 | bubble.className = "bubble"; |
| 1300 | bubble.innerHTML = view.html; |
| 1301 | if (view.codeBlocks) { codeBlocks.set(view.id, view.codeBlocks); wireCodeButtons(bubble, view.id); } |
| 1302 | return wrap("agent", "Codewhale", bubble, view); |
| 1303 | } |
| 1304 | const bubble = document.createElement("div"); |
| 1305 | bubble.className = "bubble streaming"; |
| 1306 | bubble.setAttribute("aria-busy", "true"); |
| 1307 | bubble.textContent = view.streamText || ""; |
| 1308 | streamBufs.set(view.id, bubble); |
| 1309 | return wrap("agent", "Codewhale", bubble, view); |
| 1310 | } |
| 1311 | if (view.kind === "tool_call" || view.kind === "command_execution" || view.kind === "file_change") { |
| 1312 | return wrap(view.kind, "", toolDetails(view), view); |
| 1313 | } |
| 1314 | if (view.kind === "error") { |
| 1315 | return wrap("error", "Error", plainBubble(view.summary + (view.detail ? "\\n" + view.detail : "")), view); |
| 1316 | } |
| 1317 | return wrap("status", "", plainBubble(view.summary), view); |
| 1318 | } |
| 1319 | |
| 1320 | function wrap(kind, who, body, view) { |
| 1321 | const msg = document.createElement("div"); |
| 1322 | msg.className = "msg " + kind; |
| 1323 | msg.setAttribute("role", "group"); |
| 1324 | msg.setAttribute("aria-label", who || kind.replace(/_/g, " ")); |
| 1325 | if (view) { msg.dataset.rev = String(view.rev); } |
| 1326 | if (who) { |
| 1327 | const whoEl = document.createElement("div"); |
| 1328 | whoEl.className = "who"; |
| 1329 | whoEl.textContent = who; |
| 1330 | msg.appendChild(whoEl); |
| 1331 | } |
| 1332 | msg.appendChild(body); |
| 1333 | return msg; |
| 1334 | } |
| 1335 | |
| 1336 | function plainBubble(text) { |
| 1337 | const bubble = document.createElement("div"); |
| 1338 | bubble.className = "bubble"; |
| 1339 | bubble.textContent = text; |
| 1340 | return bubble; |
| 1341 | } |
| 1342 | |
| 1343 | function toolDetails(view) { |
| 1344 | const details = document.createElement("details"); |
| 1345 | details.className = "tool"; |
| 1346 | const summary = document.createElement("summary"); |
| 1347 | summary.textContent = toolLabel(view); |
| 1348 | details.appendChild(summary); |
| 1349 | const body = document.createElement("pre"); |
| 1350 | body.textContent = view.detail || view.summary || ""; |
| 1351 | details.appendChild(body); |
| 1352 | // view.filePath is parsed extension-side (metadata.tool_input included) |
| 1353 | // and validated again before anything is opened. |
| 1354 | if (view.filePath) { |
| 1355 | const open = document.createElement("button"); |
| 1356 | open.textContent = "Open file"; |
| 1357 | open.setAttribute("aria-label", "Open file " + view.filePath); |
| 1358 | open.title = view.filePath; |
| 1359 | open.style.margin = "4px 8px"; |
| 1360 | open.addEventListener("click", () => vscode.postMessage({ command: "openFile", path: view.filePath })); |
| 1361 | details.appendChild(open); |
| 1362 | } |
| 1363 | return details; |
| 1364 | } |
| 1365 | |
| 1366 | function toolLabel(view) { |
| 1367 | const icon = view.kind === "file_change" ? "✎ " : view.kind === "command_execution" ? "▶ " : "🔧 "; |
| 1368 | return icon + (view.summary || view.kind); |
| 1369 | } |
| 1370 | |
| 1371 | function wireCodeButtons(scope, itemId) { |
| 1372 | for (const btn of scope.querySelectorAll("button.cb-copy, button.cb-insert")) { |
| 1373 | const slot = Number(btn.dataset.cb); |
| 1374 | btn.addEventListener("click", () => { |
| 1375 | const blocks = codeBlocks.get(itemId) || []; |
| 1376 | const code = blocks[slot] || ""; |
| 1377 | vscode.postMessage({ command: btn.classList.contains("cb-copy") ? "copyCode" : "insertCode", code: code }); |
| 1378 | }); |
| 1379 | } |
| 1380 | for (const link of scope.querySelectorAll("a[href]")) { |
| 1381 | link.addEventListener("click", (e) => { |
| 1382 | e.preventDefault(); |
| 1383 | vscode.postMessage({ command: "openLink", url: link.getAttribute("href") }); |
| 1384 | }); |
| 1385 | } |
| 1386 | } |
| 1387 | |
| 1388 | function appendDelta(itemId, text) { |
| 1389 | let bubble = streamBufs.get(itemId); |
| 1390 | if (!bubble) { return; } |
| 1391 | bubble.textContent += text; |
| 1392 | scrollToBottom(); |
| 1393 | } |
| 1394 | |
| 1395 | function trimEmpty() { |
| 1396 | const empty = transcript.querySelector(".empty"); |
| 1397 | if (empty && transcript.children.length > 1) { empty.remove(); } |
| 1398 | } |
| 1399 | |
| 1400 | function scrollToBottom() { |
| 1401 | transcript.scrollTop = transcript.scrollHeight; |
| 1402 | } |
| 1403 | |
| 1404 | function ensureEmpty() { |
| 1405 | if (transcript.children.length === 0) { |
| 1406 | const empty = document.createElement("div"); |
| 1407 | empty.className = "empty"; |
| 1408 | empty.textContent = "Start a task: attach context below and ask Codewhale. The same thread stays available in the terminal."; |
| 1409 | transcript.appendChild(empty); |
| 1410 | } |
| 1411 | } |
| 1412 | ensureEmpty(); |
| 1413 | `; |
| 1414 | } |
| 1415 |