返回 CodeWhale
chat.ts
根目录 / extensions / vscode / src / chat.ts
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
1415 lines TYPESCRIPT