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