index.ts

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2// SPDX-FileCopyrightText: Armin Ronacher <armin.ronacher@active-4.com>
  3//
  4// SPDX-License-Identifier: Apache-2.0
  5
  6/**
  7 * Q&A extraction hook - extracts questions from assistant responses
  8 *
  9 * Custom interactive TUI for answering questions.
 10 *
 11 * Demonstrates the "prompt generator" pattern with custom TUI:
 12 * 1. /answer command gets the last assistant message
 13 * 2. Shows a spinner while extracting questions as structured JSON
 14 * 3. Presents an interactive TUI to navigate and answer questions
 15 * 4. Submits the compiled answers when done
 16 */
 17
 18import { complete, type Tool, type UserMessage } from "@mariozechner/pi-ai";
 19import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 20import { BorderedLoader } from "@mariozechner/pi-coding-agent";
 21import {
 22	type Component,
 23	Editor,
 24	type EditorTheme,
 25	Key,
 26	matchesKey,
 27	truncateToWidth,
 28	type TUI,
 29	visibleWidth,
 30	wrapTextWithAnsi,
 31} from "@mariozechner/pi-tui";
 32import { Type } from "@sinclair/typebox";
 33
 34// Structured output format for question extraction
 35interface ExtractedQuestion {
 36	question: string;
 37	context?: string;
 38}
 39
 40interface ExtractionResult {
 41	questions: ExtractedQuestion[];
 42}
 43
 44const SYSTEM_PROMPT = `You extract questions from assistant messages so a user can answer them without reading the full message. Call the extract_questions tool with your results.
 45
 46Each extracted question must be a standalone object with a "question" string and an optional "context" string. The user will read ONLY the extracted questions, not the original message, so both the question and context must be fully self-contained.
 47
 48Rules:
 49- Each question must make sense on its own without referring back to the message (no "Issue 1", "the above", "as mentioned")
 50- Context should include all relevant details, options, and reasoning needed to answer — it replaces reading the original message
 51- Merge duplicate or overlapping questions into one (e.g. an analysis section and a plan section about the same topic become one question)
 52- Capture both explicit questions ("Which approach?") and implicit ones (proposals that need approval, recommendations that need confirmation)
 53- Keep questions in logical order
 54- If no questions are found, call the tool with an empty questions array
 55
 56<examples>
 57<example>
 58<description>Simple explicit question</description>
 59<input>I can set up the database with either PostgreSQL or MySQL. The existing infrastructure uses PostgreSQL for two other services. Which database should I use?</input>
 60<output>
 61{"question": "Which database should be used for the new service?", "context": "Options are PostgreSQL or MySQL. The existing infrastructure already uses PostgreSQL for two other services."}
 62</output>
 63</example>
 64
 65<example>
 66<description>Implicit proposal needing approval</description>
 67<input>I looked at the failing test. The flakiness comes from a race condition in the cleanup goroutine. I think the simplest fix is to add a sync.WaitGroup so the test waits for cleanup to finish before asserting. Want me to go ahead with that?</input>
 68<output>
 69{"question": "Should the flaky test be fixed by adding a sync.WaitGroup to wait for the cleanup goroutine before asserting?", "context": "The test failure is caused by a race condition in the cleanup goroutine. The WaitGroup approach ensures cleanup completes before assertions run."}
 70</output>
 71</example>
 72
 73<example>
 74<description>Long message with analysis and plan sections that overlap — questions must be merged, deduplicated, and fully standalone</description>
 75<input>
 76## Issue 1: Non-deterministic map iteration for overrides → rawArgs
 77
 78The problem is in RunE at line ~75: for k, vals := range overrides gives random order each run.
 79
 80Three good approaches:
 811. Sort the keys before iterating. Simplest, most common Go pattern.
 822. Use []restic.Flag or []struct{key, vals} instead of a map. More invasive but preserves construction order.
 833. Skip the map→rawArgs→parsePassthrough round-trip entirely. Instead of serializing overrides to rawArgs and then having runCommand parse them back, just pass the overrides map directly to runCommand and merge there. This eliminates the round-trip and the ordering problem disappears.
 84
 85Recommend approach 3 as best: it kills the root cause.
 86
 87## Issue 2: Double-pointers (**screens.Snapshot etc.)
 88
 89The problem is buildCommandScreens needs to both return screens AND communicate which specific screen instances were created back to runInteractive. Double-pointers are gnarly.
 90
 91Simpler approach: return a struct holding typed screen pointers. The ResolveFunc closure captures the struct.
 92
 93## Issue 3: Picker height overflow
 94
 95The session already subtracts chrome height via adjustedSizeMsg() before forwarding WindowSizeMsg to screens. The legacy pickerModel does its own subtraction because it runs standalone. Assessment: this finding is a false positive.
 96
 97## Issue 2.1: huh form boilerplate
 98
 99The buildForm/Init/Update boilerplate is repetitive across Overwrite, Target, Preset, Snapshot. Code is simple and clear in each screen. Recommendation: skip — borderline DRY.
100
101## Issue 2.2: drain test helpers
102
103The drain* test helpers are copy-pasted with only the type changed. Perfect case for Go generics: func drain[S ui.Screen](s S, cmd tea.Cmd) (S, tea.Cmd)
104
105## Issue 3.1: Snapshot theme handler clears user input
106
107When BackgroundColorMsg arrives during phaseSnapshotManual, buildManualForm() resets s.entered = "". Fix: preserve s.entered before rebuilding. Also needs rebuildSelectForm() call during phaseSnapshotSelecting.
108
109## Issue 4: WindowSizeMsg missed during phaseFileLoading
110
111If the screen is loading when the initial WindowSizeMsg arrives, it only goes to the spinner and the size is lost. Fix: store the last WindowSizeMsg on the FilePicker struct, apply when picker is built.
112
113## Issue 5.1: Same as Issue 1
114
115Same root cause — map iteration in RunE. Fix for Issue 1 handles this.
116
117## Issue 6.1: Duplicated constant
118
119Both internal/config and internal/restic define the same commandSuffix constant.
120
121## Issue 6.2: Inconsistent empty-string semantics
122
123rc.Environ treats "" as present (checks map key existence), but os.Getenv checks != "" so an empty env var counts as absent.
124
125## Plan
126
1271. Remove legacy restore prompts (promptRestore, promptSnapshotID, promptFileSelection, printRestoreSummary, standalone pickerModel)
1282. Eliminate map→rawArgs round-trip (Issues 1 and 5.1)
1293. Replace double-pointers with return struct (Issue 2)
1304. Extract generic drain test helper (Issue 2.2)
1315. Fix snapshot theme handler (Issue 3.1)
1326. Store WindowSizeMsg during loading (Issue 4)
1337. Share commandSuffix constant (Issue 6.1)
1348. Fix inconsistent empty-string check (Issue 6.2)
135
136NOT planning to do Issue 2.1 (huh form boilerplate extraction).
137</input>
138<output>
139{"question": "How should the non-deterministic map iteration in RunE be fixed, where 'for k, vals := range overrides' produces random argument order each run?", "context": "Three approaches: (1) Sort map keys before iterating — simplest, small code change. (2) Replace map[string][]string with a slice of key-value pairs to preserve construction order. (3) Eliminate the map→rawArgs→parsePassthrough round-trip entirely by passing overrides directly to runCommand — this also fixes the identical issue in rawArgs parsing. Recommended: approach 3."}
140{"question": "Should the double-pointer output parameters (**screens.Snapshot, etc.) in buildCommandScreens be replaced with a returned struct?", "context": "Currently uses double-pointers so runInteractive can read results from specific screen instances. A commandScreens struct holding typed screen pointers would be returned instead, and the ResolveFunc closure captures it. Idiomatic Go — return a struct instead of output parameters."}
141{"question": "The picker height overflow appears to be a false positive because the session already subtracts chrome height via adjustedSizeMsg() before forwarding WindowSizeMsg to screens. Safe to skip?", "context": "session.go subtracts 5 chrome lines (breadcrumb + title + help bar) before forwarding. The legacy pickerModel does its own subtraction because it runs standalone with tea.NewProgram. The screen adapter doesn't need additional subtraction."}
142{"question": "Should the repeated huh form boilerplate (buildForm/Init/Update) across Overwrite, Target, Preset, and Snapshot screens be extracted into a shared helper?", "context": "Each screen repeats same form setup pattern with slight behavior variations. Code is currently simple and self-contained in each screen. Recommended: skip — borderline DRY."}
143{"question": "Should the copy-pasted type-specific drain test helpers be replaced with a single generic drain[S ui.Screen] function?", "context": "Multiple test files have identical drain helpers differing only in type parameter (e.g. drainSnapshot, drainFilePicker). A single generic function would replace all of them."}
144{"question": "Should the snapshot theme handler be fixed to preserve user input when the terminal theme changes?", "context": "When BackgroundColorMsg arrives during phaseSnapshotManual, buildManualForm() resets s.entered to empty, wiping user input. Fix: preserve s.entered before rebuild. Also needs rebuildSelectForm() call during phaseSnapshotSelecting."}
145{"question": "Should FilePicker store the last WindowSizeMsg to prevent size loss during the loading phase?", "context": "If the screen is loading when the initial WindowSizeMsg arrives, the message only goes to the spinner and size is lost. When buildPicker runs later, picker doesn't know terminal size. Fix: add lastSize field, apply when picker built."}
146{"question": "Should the duplicated commandSuffix constant be extracted to a shared package?", "context": "Both internal/config and internal/restic define the same constant."}
147{"question": "Should os.Getenv() != '' checks be replaced with os.LookupEnv so empty environment variables count as present?", "context": "rc.Environ treats empty string as present (checks map key existence), but os.Getenv checks != '' so an empty env var counts as absent. Using os.LookupEnv would make behavior consistent — explicitly setting RESTIC_REPOSITORY='' would count as configured."}
148{"question": "Should the legacy interactive restore prompts (promptRestore, promptSnapshotID, promptFileSelection, printRestoreSummary, standalone pickerModel) be removed from root.go?", "context": "The TUI session now handles the full restore flow through screens, making these standalone prompt functions unused."}
149</output>
150</example>
151</examples>`;
152
153/**
154 * Tool definition for structured question extraction. Models produce
155 * structured tool-call arguments far more reliably than free-form JSON
156 * in a text response.
157 */
158const QUESTION_EXTRACTION_TOOL: Tool = {
159	name: "extract_questions",
160	description:
161		"Extract questions from the assistant's message. Each question is a self-contained object the user will read instead of the original message.",
162	parameters: Type.Object({
163		questions: Type.Array(
164			Type.Object({
165				question: Type.String({
166					description: "A complete, standalone question. Must make sense without reading the original message.",
167				}),
168				context: Type.Optional(
169					Type.String({
170						description:
171							"Self-contained context with all details, options, and reasoning needed to answer the question.",
172					}),
173				),
174			}),
175			{
176				description:
177					"Array of question objects. Merge overlapping questions. Each item MUST be an object with 'question' and optional 'context' strings, NOT a plain string.",
178			},
179		),
180	}),
181};
182
183// Preferred model for extraction — lightweight is fine for structured JSON output.
184// Falls back to the session model if this one isn't available.
185const PREFERRED_EXTRACTION_MODEL_ID = "nemotron-3-super-120b-a12b";
186
187/**
188 * Resolve the extraction model: prefer a lightweight model from the registry,
189 * fall back to the current session model.
190 */
191function resolveExtractionModel(ctx: ExtensionContext) {
192	const available = ctx.modelRegistry.getAvailable();
193	const preferred = available.find((m) => m.id === PREFERRED_EXTRACTION_MODEL_ID);
194	return preferred ?? ctx.model!;
195}
196
197/**
198 * Parse a tool call response into an ExtractionResult.
199 */
200function parseToolCallResult(response: Awaited<ReturnType<typeof complete>>): ExtractionResult | null {
201	const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_questions");
202
203	if (!toolCall || toolCall.type !== "toolCall") {
204		console.error("Model did not call extract_questions:", response.content);
205		return null;
206	}
207
208	const args = toolCall.arguments as Record<string, unknown>;
209	if (!Array.isArray(args.questions)) {
210		console.error("[answer] expected questions array, got:", typeof args.questions, args);
211		return null;
212	}
213
214	// Validate each question item — model may return plain strings instead
215	// of objects if it ignores the schema, so we handle both gracefully.
216	const questions: ExtractedQuestion[] = [];
217	for (const item of args.questions) {
218		if (typeof item === "string") {
219			// Model returned bare string instead of object — use as question text
220			questions.push({ question: item });
221			continue;
222		}
223		if (typeof item !== "object" || item === null) continue;
224		const obj = item as Record<string, unknown>;
225		if (typeof obj.question !== "string") {
226			console.error("[answer] skipping item with no 'question' string:", Object.keys(obj));
227			continue;
228		}
229		questions.push({
230			question: obj.question,
231			context: typeof obj.context === "string" ? obj.context : undefined,
232		});
233	}
234
235	return { questions };
236}
237
238/**
239 * Interactive Q&A component for answering extracted questions
240 */
241class QnAComponent implements Component {
242	private questions: ExtractedQuestion[];
243	private answers: string[];
244	private currentIndex: number = 0;
245	private editor: Editor;
246	private tui: TUI;
247	private onDone: (result: string | null) => void;
248	private showingConfirmation: boolean = false;
249	private notesMode: boolean = false;
250	private notesText: string = "";
251	private notesEditor: Editor;
252
253	// Cache
254	private cachedWidth?: number;
255	private cachedLines?: string[];
256
257	// Colors - using proper reset sequences
258	private dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
259	private bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
260	private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
261	private green = (s: string) => `\x1b[32m${s}\x1b[0m`;
262	private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
263	private gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
264
265	constructor(questions: ExtractedQuestion[], tui: TUI, onDone: (result: string | null) => void) {
266		this.questions = questions;
267		this.answers = questions.map(() => "");
268		this.tui = tui;
269		this.onDone = onDone;
270
271		// Create a minimal theme for the editor
272		const editorTheme: EditorTheme = {
273			borderColor: this.dim,
274			selectList: {
275				selectedBg: (s: string) => `\x1b[44m${s}\x1b[0m`,
276				matchHighlight: this.cyan,
277				itemSecondary: this.gray,
278			},
279		};
280
281		this.editor = new Editor(tui, editorTheme);
282		// Disable the editor's built-in submit (which clears the editor)
283		// We'll handle Enter ourselves to preserve the text
284		this.editor.disableSubmit = true;
285		this.editor.onChange = () => {
286			this.invalidate();
287			this.tui.requestRender();
288		};
289
290		this.notesEditor = new Editor(tui, editorTheme);
291		this.notesEditor.onSubmit = (value) => {
292			this.notesText = value.trim();
293			this.notesMode = false;
294			this.invalidate();
295			this.tui.requestRender();
296		};
297	}
298
299	private allQuestionsAnswered(): boolean {
300		this.saveCurrentAnswer();
301		return this.answers.every((a) => (a?.trim() || "").length > 0);
302	}
303
304	private saveCurrentAnswer(): void {
305		this.answers[this.currentIndex] = this.editor.getText();
306	}
307
308	private navigateTo(index: number): void {
309		if (index < 0 || index >= this.questions.length) return;
310		this.saveCurrentAnswer();
311		this.currentIndex = index;
312		this.editor.setText(this.answers[index] || "");
313		this.invalidate();
314	}
315
316	private submit(): void {
317		this.saveCurrentAnswer();
318
319		// Build the response text
320		const parts: string[] = [];
321		parts.push(`<qna>`);
322		for (let i = 0; i < this.questions.length; i++) {
323			const q = this.questions[i];
324			const a = this.answers[i]?.trim() || "(no answer)";
325			parts.push(`<q n="${i}">${q.question}</q>`);
326			parts.push(`<a n="${i}">${a}</a>`);
327		}
328		parts.push(`</qna>`);
329		if (this.notesText) {
330			parts.push(`\n<note>${this.notesText}</note>`);
331		}
332
333		this.onDone(parts.join("\n").trim());
334	}
335
336	private cancel(): void {
337		this.onDone(null);
338	}
339
340	invalidate(): void {
341		this.cachedWidth = undefined;
342		this.cachedLines = undefined;
343	}
344
345	handleInput(data: string): void {
346		// Notes mode: route to notes editor
347		if (this.notesMode) {
348			if (matchesKey(data, Key.escape)) {
349				this.notesMode = false;
350				this.notesEditor.setText(this.notesText);
351				this.invalidate();
352				this.tui.requestRender();
353				return;
354			}
355			this.notesEditor.handleInput(data);
356			this.invalidate();
357			this.tui.requestRender();
358			return;
359		}
360
361		// Handle confirmation dialog
362		if (this.showingConfirmation) {
363			if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
364				this.submit();
365				return;
366			}
367			if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") {
368				this.showingConfirmation = false;
369				this.invalidate();
370				this.tui.requestRender();
371				return;
372			}
373			return;
374		}
375
376		// Global navigation and commands
377		if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
378			this.cancel();
379			return;
380		}
381
382		// Activate notes editor (available when not typing an answer)
383		if (matchesKey(data, Key.alt("n"))) {
384			this.notesMode = true;
385			this.notesEditor.setText(this.notesText);
386			this.invalidate();
387			this.tui.requestRender();
388			return;
389		}
390
391		// Tab / Shift+Tab for navigation
392		if (matchesKey(data, Key.tab)) {
393			if (this.currentIndex < this.questions.length - 1) {
394				this.navigateTo(this.currentIndex + 1);
395				this.tui.requestRender();
396			}
397			return;
398		}
399		if (matchesKey(data, Key.shift("tab"))) {
400			if (this.currentIndex > 0) {
401				this.navigateTo(this.currentIndex - 1);
402				this.tui.requestRender();
403			}
404			return;
405		}
406
407		// Arrow up/down for question navigation when editor is empty
408		// (Editor handles its own cursor navigation when there's content)
409		if (matchesKey(data, Key.up) && this.editor.getText() === "") {
410			if (this.currentIndex > 0) {
411				this.navigateTo(this.currentIndex - 1);
412				this.tui.requestRender();
413				return;
414			}
415		}
416		if (matchesKey(data, Key.down) && this.editor.getText() === "") {
417			if (this.currentIndex < this.questions.length - 1) {
418				this.navigateTo(this.currentIndex + 1);
419				this.tui.requestRender();
420				return;
421			}
422		}
423
424		// Handle Enter ourselves (editor's submit is disabled)
425		// Plain Enter moves to next question or shows confirmation on last question
426		// Shift+Enter adds a newline (handled by editor)
427		if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
428			this.saveCurrentAnswer();
429			if (this.currentIndex < this.questions.length - 1) {
430				this.navigateTo(this.currentIndex + 1);
431			} else {
432				// On last question - show confirmation
433				this.showingConfirmation = true;
434			}
435			this.invalidate();
436			this.tui.requestRender();
437			return;
438		}
439
440		// Pass to editor
441		this.editor.handleInput(data);
442		this.invalidate();
443		this.tui.requestRender();
444	}
445
446	render(width: number): string[] {
447		if (this.cachedLines && this.cachedWidth === width) {
448			return this.cachedLines;
449		}
450
451		const lines: string[] = [];
452		const boxWidth = Math.min(width - 4, 120); // Allow wider box
453		const contentWidth = boxWidth - 4; // 2 chars padding on each side
454
455		// Helper to create horizontal lines (dim the whole thing at once)
456		const horizontalLine = (count: number) => "─".repeat(count);
457
458		// Helper to create a box line
459		const boxLine = (content: string, leftPad: number = 2): string => {
460			const paddedContent = " ".repeat(leftPad) + content;
461			const contentLen = visibleWidth(paddedContent);
462			const rightPad = Math.max(0, boxWidth - contentLen - 2);
463			return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
464		};
465
466		const emptyBoxLine = (): string => {
467			return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
468		};
469
470		const padToWidth = (line: string): string => {
471			const len = visibleWidth(line);
472			return line + " ".repeat(Math.max(0, width - len));
473		};
474
475		// Title
476		lines.push(padToWidth(this.dim("â•­" + horizontalLine(boxWidth - 2) + "â•®")));
477		const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
478		lines.push(padToWidth(boxLine(title)));
479		lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
480
481		// Progress indicator
482		const progressParts: string[] = [];
483		for (let i = 0; i < this.questions.length; i++) {
484			const answered = (this.answers[i]?.trim() || "").length > 0;
485			const current = i === this.currentIndex;
486			if (current) {
487				progressParts.push(this.cyan("â—Ź"));
488			} else if (answered) {
489				progressParts.push(this.green("â—Ź"));
490			} else {
491				progressParts.push(this.dim("â—‹"));
492			}
493		}
494		lines.push(padToWidth(boxLine(progressParts.join(" "))));
495		lines.push(padToWidth(emptyBoxLine()));
496
497		// Current question
498		const q = this.questions[this.currentIndex];
499		const questionText = `${this.bold("Q:")} ${q.question}`;
500		const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
501		for (const line of wrappedQuestion) {
502			lines.push(padToWidth(boxLine(line)));
503		}
504
505		// Context if present
506		if (q.context) {
507			lines.push(padToWidth(emptyBoxLine()));
508			const contextText = this.gray(`> ${q.context}`);
509			const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
510			for (const line of wrappedContext) {
511				lines.push(padToWidth(boxLine(line)));
512			}
513		}
514
515		lines.push(padToWidth(emptyBoxLine()));
516
517		// Render the editor component (multi-line input) with padding
518		// Skip the first and last lines (editor's own border lines)
519		const answerPrefix = this.bold("A: ");
520		const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
521		const editorLines = this.editor.render(editorWidth);
522		for (let i = 1; i < editorLines.length - 1; i++) {
523			if (i === 1) {
524				// First content line gets the "A: " prefix
525				lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
526			} else {
527				// Subsequent lines get padding to align with the first line
528				lines.push(padToWidth(boxLine("   " + editorLines[i])));
529			}
530		}
531
532		// Notes section
533		if (this.notesMode) {
534			lines.push(padToWidth(emptyBoxLine()));
535			const notesLabel = `${this.cyan("✎")} ${this.bold("Note:")} ${this.dim("(Enter to save, Esc to discard)")}`;
536			lines.push(padToWidth(boxLine(notesLabel)));
537			const notesEditorWidth = contentWidth - 4;
538			const notesEditorLines = this.notesEditor.render(notesEditorWidth);
539			for (let i = 1; i < notesEditorLines.length - 1; i++) {
540				lines.push(padToWidth(boxLine("  " + notesEditorLines[i])));
541			}
542		} else if (this.notesText) {
543			lines.push(padToWidth(emptyBoxLine()));
544			const savedNote = `${this.cyan("✎")} ${this.gray(this.notesText)}`;
545			const wrappedNote = wrapTextWithAnsi(savedNote, contentWidth);
546			for (const line of wrappedNote) {
547				lines.push(padToWidth(boxLine(line)));
548			}
549			lines.push(padToWidth(boxLine(this.dim("Alt+N to edit note"))));
550		} else {
551			lines.push(padToWidth(emptyBoxLine()));
552			lines.push(padToWidth(boxLine(this.dim("Alt+N to add a note"))));
553		}
554
555		lines.push(padToWidth(emptyBoxLine()));
556
557		// Confirmation dialog or footer with controls
558		if (this.showingConfirmation) {
559			lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
560			const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
561			lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
562		} else {
563			lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
564			const controls = `${this.dim("Tab/Enter")} next · ${this.dim("Shift+Tab")} prev · ${this.dim("Shift+Enter")} newline · ${this.dim("Alt+N")} note · ${this.dim("Esc")} cancel`;
565			lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
566		}
567		lines.push(padToWidth(this.dim("╰" + horizontalLine(boxWidth - 2) + "╯")));
568
569		this.cachedWidth = width;
570		this.cachedLines = lines;
571		return lines;
572	}
573}
574
575export default function (pi: ExtensionAPI) {
576	const answerHandler = async (ctx: ExtensionContext, instruction?: string) => {
577		if (!ctx.hasUI) {
578			ctx.ui.notify("answer requires interactive mode", "error");
579			return;
580		}
581
582		if (!ctx.model) {
583			ctx.ui.notify("No model selected", "error");
584			return;
585		}
586
587		// Find the last assistant message on the current branch
588		const branch = ctx.sessionManager.getBranch();
589		let lastAssistantText: string | undefined;
590
591		for (let i = branch.length - 1; i >= 0; i--) {
592			const entry = branch[i];
593			if (entry.type === "message") {
594				const msg = entry.message;
595				if ("role" in msg && msg.role === "assistant") {
596					if (msg.stopReason !== "stop") {
597						ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
598						return;
599					}
600					const textParts = msg.content
601						.filter((c): c is { type: "text"; text: string } => c.type === "text")
602						.map((c) => c.text);
603					if (textParts.length > 0) {
604						lastAssistantText = textParts.join("\n");
605						break;
606					}
607				}
608			}
609		}
610
611		if (!lastAssistantText) {
612			ctx.ui.notify("No assistant messages found", "error");
613			return;
614		}
615
616		// Run extraction with loader UI.
617		// The result distinguishes user cancellation from errors so we can
618		// show the right message after the custom UI closes.
619		type ExtractionOutcome =
620			| { kind: "ok"; result: ExtractionResult }
621			| { kind: "cancelled" }
622			| { kind: "error"; message: string };
623
624		const outcome = await ctx.ui.custom<ExtractionOutcome>((tui, theme, _kb, done) => {
625			const extractionModel = resolveExtractionModel(ctx);
626			const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.name}...`);
627			loader.onAbort = () => done({ kind: "cancelled" });
628
629			const tryExtract = async (model: ReturnType<typeof resolveExtractionModel>) => {
630				const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
631				if (!auth.ok) {
632					return { kind: "model_error" as const, model };
633				}
634				const instructionBlock = instruction
635					? `<user_instruction>\n${instruction}\n</user_instruction>\n\nExtract questions from the assistant's message based on the user's instruction above.`
636					: "Extract questions from the assistant's message for the user to fill out.";
637
638				const userMessage: UserMessage = {
639					role: "user",
640					content: [
641						{
642							type: "text",
643							text: `<last_assistant_message>\n${lastAssistantText!}\n</last_assistant_message>\n\n${instructionBlock}`,
644						},
645					],
646					timestamp: Date.now(),
647				};
648
649				const response = await complete(
650					model,
651					{
652						systemPrompt: SYSTEM_PROMPT,
653						messages: [userMessage],
654						tools: [QUESTION_EXTRACTION_TOOL],
655					},
656					{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
657				);
658
659				if (response.stopReason === "aborted") {
660					return { kind: "cancelled" as const };
661				}
662
663				if (response.stopReason === "error" || response.content.length === 0) {
664					return { kind: "model_error" as const, model };
665				}
666
667				const parsed = parseToolCallResult(response);
668				if (!parsed) {
669					return {
670						kind: "parse_error" as const,
671						message: `${model.name} did not call extract_questions tool`,
672					};
673				}
674				return { kind: "ok" as const, result: parsed };
675			};
676
677			const doExtract = async () => {
678				let result = await tryExtract(extractionModel);
679
680				// If the preferred model errored and it's not already the
681				// session model, fall back to the session model.
682				if (result.kind === "model_error" && extractionModel.id !== ctx.model!.id) {
683					ctx.ui.notify(`${extractionModel.name} unavailable, falling back to ${ctx.model!.name}...`, "warning");
684					result = await tryExtract(ctx.model!);
685				}
686
687				switch (result.kind) {
688					case "ok":
689						return done({ kind: "ok", result: result.result });
690					case "cancelled":
691						return done({ kind: "cancelled" });
692					case "model_error":
693						return done({
694							kind: "error",
695							message: `${result.model.name} returned an error with no content`,
696						});
697					case "parse_error":
698						return done({ kind: "error", message: result.message });
699				}
700			};
701
702			doExtract().catch((err) =>
703				done({
704					kind: "error",
705					message: `${err?.message ?? err}`,
706				}),
707			);
708
709			return loader;
710		});
711
712		if (outcome.kind === "cancelled") {
713			ctx.ui.notify("Cancelled", "info");
714			return;
715		}
716		if (outcome.kind === "error") {
717			ctx.ui.notify(`Extraction failed: ${outcome.message}`, "error");
718			return;
719		}
720
721		const extractionResult = outcome.result;
722
723		if (extractionResult.questions.length === 0) {
724			ctx.ui.notify("No questions found in the last message", "info");
725			return;
726		}
727
728		// Show the Q&A component
729		const answersResult = await ctx.ui.custom<string | null>((tui, _theme, _kb, done) => {
730			return new QnAComponent(extractionResult.questions, tui, done);
731		});
732
733		if (answersResult === null) {
734			ctx.ui.notify("Cancelled", "info");
735			return;
736		}
737
738		// Send the answers directly as a message and trigger a turn
739		pi.sendMessage(
740			{
741				customType: "answers",
742				content: "I answered your questions in the following way:\n\n" + answersResult,
743				display: true,
744			},
745			{ triggerTurn: true },
746		);
747	};
748
749	pi.registerCommand("answer", {
750		description:
751			"Extract questions from last assistant message into interactive Q&A. Optional: provide instructions for what to extract.",
752		handler: (args, ctx) => answerHandler(ctx, args?.trim() || undefined),
753	});
754
755	pi.registerShortcut("ctrl+.", {
756		description: "Extract and answer questions",
757		handler: answerHandler,
758	});
759}