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