Add answer extension

Amolith created

Interactive Q&A extraction from assistant responses with a custom TUI
for navigating and answering questions. Based on Armin Ronacher's answer
extension from agent-stuff, with several changes:

- Replaced responseFormat-based extraction with a structured
tool call (extract_questions), which models handle more reliably
- Rewrote the system prompt with detailed few-shot examples
including deduplication and standalone-question rules
- Changed model selection to prefer nemotron-3-super-120b-a12b
instead of codex-mini/haiku
- Added a notes editor (Alt+N) for attaching free-text context
alongside answers
- Added fallback extraction with current model when the preferred
model fails

Licensed Apache-2.0 per the upstream.

Change summary

packages/answer/package.json          |  23 
packages/answer/package.json.license  |   3 
packages/answer/src/index.ts          | 759 +++++++++++++++++++++++++++++
packages/answer/tsconfig.json         |   4 
packages/answer/tsconfig.json.license |   3 
5 files changed, 792 insertions(+)

Detailed changes

packages/answer/package.json đź”—

@@ -0,0 +1,23 @@
+{
+	"name": "@amolith/pi-answer",
+	"version": "0.1.0",
+	"description": "Interactive Q&A extraction from assistant responses for Pi",
+	"keywords": [
+		"pi-package"
+	],
+	"pi": {
+		"extensions": [
+			"./src/index.ts"
+		]
+	},
+	"scripts": {
+		"typecheck": "tsc --noEmit"
+	},
+	"peerDependencies": {
+		"@mariozechner/pi-ai": "*",
+		"@mariozechner/pi-coding-agent": "*",
+		"@mariozechner/pi-tui": "*",
+		"@sinclair/typebox": "*"
+	},
+	"type": "module"
+}

packages/answer/src/index.ts đź”—

@@ -0,0 +1,759 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Armin Ronacher <armin.ronacher@active-4.com>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Q&A extraction hook - extracts questions from assistant responses
+ *
+ * Custom interactive TUI for answering questions.
+ *
+ * Demonstrates the "prompt generator" pattern with custom TUI:
+ * 1. /answer command gets the last assistant message
+ * 2. Shows a spinner while extracting questions as structured JSON
+ * 3. Presents an interactive TUI to navigate and answer questions
+ * 4. Submits the compiled answers when done
+ */
+
+import { complete, type Tool, type UserMessage } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { BorderedLoader } from "@mariozechner/pi-coding-agent";
+import {
+	type Component,
+	Editor,
+	type EditorTheme,
+	Key,
+	matchesKey,
+	truncateToWidth,
+	type TUI,
+	visibleWidth,
+	wrapTextWithAnsi,
+} from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+
+// Structured output format for question extraction
+interface ExtractedQuestion {
+	question: string;
+	context?: string;
+}
+
+interface ExtractionResult {
+	questions: ExtractedQuestion[];
+}
+
+const 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.
+
+Each 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.
+
+Rules:
+- Each question must make sense on its own without referring back to the message (no "Issue 1", "the above", "as mentioned")
+- Context should include all relevant details, options, and reasoning needed to answer — it replaces reading the original message
+- Merge duplicate or overlapping questions into one (e.g. an analysis section and a plan section about the same topic become one question)
+- Capture both explicit questions ("Which approach?") and implicit ones (proposals that need approval, recommendations that need confirmation)
+- Keep questions in logical order
+- If no questions are found, call the tool with an empty questions array
+
+<examples>
+<example>
+<description>Simple explicit question</description>
+<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>
+<output>
+{"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."}
+</output>
+</example>
+
+<example>
+<description>Implicit proposal needing approval</description>
+<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>
+<output>
+{"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."}
+</output>
+</example>
+
+<example>
+<description>Long message with analysis and plan sections that overlap — questions must be merged, deduplicated, and fully standalone</description>
+<input>
+## Issue 1: Non-deterministic map iteration for overrides → rawArgs
+
+The problem is in RunE at line ~75: for k, vals := range overrides gives random order each run.
+
+Three good approaches:
+1. Sort the keys before iterating. Simplest, most common Go pattern.
+2. Use []restic.Flag or []struct{key, vals} instead of a map. More invasive but preserves construction order.
+3. 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.
+
+Recommend approach 3 as best: it kills the root cause.
+
+## Issue 2: Double-pointers (**screens.Snapshot etc.)
+
+The problem is buildCommandScreens needs to both return screens AND communicate which specific screen instances were created back to runInteractive. Double-pointers are gnarly.
+
+Simpler approach: return a struct holding typed screen pointers. The ResolveFunc closure captures the struct.
+
+## Issue 3: Picker height overflow
+
+The 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.
+
+## Issue 2.1: huh form boilerplate
+
+The buildForm/Init/Update boilerplate is repetitive across Overwrite, Target, Preset, Snapshot. Code is simple and clear in each screen. Recommendation: skip — borderline DRY.
+
+## Issue 2.2: drain test helpers
+
+The 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)
+
+## Issue 3.1: Snapshot theme handler clears user input
+
+When BackgroundColorMsg arrives during phaseSnapshotManual, buildManualForm() resets s.entered = "". Fix: preserve s.entered before rebuilding. Also needs rebuildSelectForm() call during phaseSnapshotSelecting.
+
+## Issue 4: WindowSizeMsg missed during phaseFileLoading
+
+If 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.
+
+## Issue 5.1: Same as Issue 1
+
+Same root cause — map iteration in RunE. Fix for Issue 1 handles this.
+
+## Issue 6.1: Duplicated constant
+
+Both internal/config and internal/restic define the same commandSuffix constant.
+
+## Issue 6.2: Inconsistent empty-string semantics
+
+rc.Environ treats "" as present (checks map key existence), but os.Getenv checks != "" so an empty env var counts as absent.
+
+## Plan
+
+1. Remove legacy restore prompts (promptRestore, promptSnapshotID, promptFileSelection, printRestoreSummary, standalone pickerModel)
+2. Eliminate map→rawArgs round-trip (Issues 1 and 5.1)
+3. Replace double-pointers with return struct (Issue 2)
+4. Extract generic drain test helper (Issue 2.2)
+5. Fix snapshot theme handler (Issue 3.1)
+6. Store WindowSizeMsg during loading (Issue 4)
+7. Share commandSuffix constant (Issue 6.1)
+8. Fix inconsistent empty-string check (Issue 6.2)
+
+NOT planning to do Issue 2.1 (huh form boilerplate extraction).
+</input>
+<output>
+{"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."}
+{"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."}
+{"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."}
+{"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."}
+{"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."}
+{"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."}
+{"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."}
+{"question": "Should the duplicated commandSuffix constant be extracted to a shared package?", "context": "Both internal/config and internal/restic define the same constant."}
+{"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."}
+{"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."}
+</output>
+</example>
+</examples>`;
+
+/**
+ * Tool definition for structured question extraction. Models produce
+ * structured tool-call arguments far more reliably than free-form JSON
+ * in a text response.
+ */
+const QUESTION_EXTRACTION_TOOL: Tool = {
+	name: "extract_questions",
+	description:
+		"Extract questions from the assistant's message. Each question is a self-contained object the user will read instead of the original message.",
+	parameters: Type.Object({
+		questions: Type.Array(
+			Type.Object({
+				question: Type.String({
+					description: "A complete, standalone question. Must make sense without reading the original message.",
+				}),
+				context: Type.Optional(
+					Type.String({
+						description:
+							"Self-contained context with all details, options, and reasoning needed to answer the question.",
+					}),
+				),
+			}),
+			{
+				description:
+					"Array of question objects. Merge overlapping questions. Each item MUST be an object with 'question' and optional 'context' strings, NOT a plain string.",
+			},
+		),
+	}),
+};
+
+// Preferred model for extraction — lightweight is fine for structured JSON output.
+// Falls back to the session model if this one isn't available.
+const PREFERRED_EXTRACTION_MODEL_ID = "nemotron-3-super-120b-a12b";
+
+/**
+ * Resolve the extraction model: prefer a lightweight model from the registry,
+ * fall back to the current session model.
+ */
+function resolveExtractionModel(ctx: ExtensionContext) {
+	const available = ctx.modelRegistry.getAvailable();
+	const preferred = available.find((m) => m.id === PREFERRED_EXTRACTION_MODEL_ID);
+	return preferred ?? ctx.model!;
+}
+
+/**
+ * Parse a tool call response into an ExtractionResult.
+ */
+function parseToolCallResult(response: Awaited<ReturnType<typeof complete>>): ExtractionResult | null {
+	const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_questions");
+
+	if (!toolCall || toolCall.type !== "toolCall") {
+		console.error("Model did not call extract_questions:", response.content);
+		return null;
+	}
+
+	const args = toolCall.arguments as Record<string, unknown>;
+	if (!Array.isArray(args.questions)) {
+		console.error("[answer] expected questions array, got:", typeof args.questions, args);
+		return null;
+	}
+
+	// Validate each question item — model may return plain strings instead
+	// of objects if it ignores the schema, so we handle both gracefully.
+	const questions: ExtractedQuestion[] = [];
+	for (const item of args.questions) {
+		if (typeof item === "string") {
+			// Model returned bare string instead of object — use as question text
+			questions.push({ question: item });
+			continue;
+		}
+		if (typeof item !== "object" || item === null) continue;
+		const obj = item as Record<string, unknown>;
+		if (typeof obj.question !== "string") {
+			console.error("[answer] skipping item with no 'question' string:", Object.keys(obj));
+			continue;
+		}
+		questions.push({
+			question: obj.question,
+			context: typeof obj.context === "string" ? obj.context : undefined,
+		});
+	}
+
+	return { questions };
+}
+
+/**
+ * Interactive Q&A component for answering extracted questions
+ */
+class QnAComponent implements Component {
+	private questions: ExtractedQuestion[];
+	private answers: string[];
+	private currentIndex: number = 0;
+	private editor: Editor;
+	private tui: TUI;
+	private onDone: (result: string | null) => void;
+	private showingConfirmation: boolean = false;
+	private notesMode: boolean = false;
+	private notesText: string = "";
+	private notesEditor: Editor;
+
+	// Cache
+	private cachedWidth?: number;
+	private cachedLines?: string[];
+
+	// Colors - using proper reset sequences
+	private dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
+	private bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
+	private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
+	private green = (s: string) => `\x1b[32m${s}\x1b[0m`;
+	private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
+	private gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
+
+	constructor(questions: ExtractedQuestion[], tui: TUI, onDone: (result: string | null) => void) {
+		this.questions = questions;
+		this.answers = questions.map(() => "");
+		this.tui = tui;
+		this.onDone = onDone;
+
+		// Create a minimal theme for the editor
+		const editorTheme: EditorTheme = {
+			borderColor: this.dim,
+			selectList: {
+				selectedBg: (s: string) => `\x1b[44m${s}\x1b[0m`,
+				matchHighlight: this.cyan,
+				itemSecondary: this.gray,
+			},
+		};
+
+		this.editor = new Editor(tui, editorTheme);
+		// Disable the editor's built-in submit (which clears the editor)
+		// We'll handle Enter ourselves to preserve the text
+		this.editor.disableSubmit = true;
+		this.editor.onChange = () => {
+			this.invalidate();
+			this.tui.requestRender();
+		};
+
+		this.notesEditor = new Editor(tui, editorTheme);
+		this.notesEditor.onSubmit = (value) => {
+			this.notesText = value.trim();
+			this.notesMode = false;
+			this.invalidate();
+			this.tui.requestRender();
+		};
+	}
+
+	private allQuestionsAnswered(): boolean {
+		this.saveCurrentAnswer();
+		return this.answers.every((a) => (a?.trim() || "").length > 0);
+	}
+
+	private saveCurrentAnswer(): void {
+		this.answers[this.currentIndex] = this.editor.getText();
+	}
+
+	private navigateTo(index: number): void {
+		if (index < 0 || index >= this.questions.length) return;
+		this.saveCurrentAnswer();
+		this.currentIndex = index;
+		this.editor.setText(this.answers[index] || "");
+		this.invalidate();
+	}
+
+	private submit(): void {
+		this.saveCurrentAnswer();
+
+		// Build the response text
+		const parts: string[] = [];
+		parts.push(`<qna>`);
+		for (let i = 0; i < this.questions.length; i++) {
+			const q = this.questions[i];
+			const a = this.answers[i]?.trim() || "(no answer)";
+			parts.push(`<q n="${i}">${q.question}</q>`);
+			parts.push(`<a n="${i}">${a}</a>`);
+		}
+		parts.push(`</qna>`);
+		if (this.notesText) {
+			parts.push(`\n<note>${this.notesText}</note>`);
+		}
+
+		this.onDone(parts.join("\n").trim());
+	}
+
+	private cancel(): void {
+		this.onDone(null);
+	}
+
+	invalidate(): void {
+		this.cachedWidth = undefined;
+		this.cachedLines = undefined;
+	}
+
+	handleInput(data: string): void {
+		// Notes mode: route to notes editor
+		if (this.notesMode) {
+			if (matchesKey(data, Key.escape)) {
+				this.notesMode = false;
+				this.notesEditor.setText(this.notesText);
+				this.invalidate();
+				this.tui.requestRender();
+				return;
+			}
+			this.notesEditor.handleInput(data);
+			this.invalidate();
+			this.tui.requestRender();
+			return;
+		}
+
+		// Handle confirmation dialog
+		if (this.showingConfirmation) {
+			if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
+				this.submit();
+				return;
+			}
+			if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") {
+				this.showingConfirmation = false;
+				this.invalidate();
+				this.tui.requestRender();
+				return;
+			}
+			return;
+		}
+
+		// Global navigation and commands
+		if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
+			this.cancel();
+			return;
+		}
+
+		// Activate notes editor (available when not typing an answer)
+		if (matchesKey(data, Key.alt("n"))) {
+			this.notesMode = true;
+			this.notesEditor.setText(this.notesText);
+			this.invalidate();
+			this.tui.requestRender();
+			return;
+		}
+
+		// Tab / Shift+Tab for navigation
+		if (matchesKey(data, Key.tab)) {
+			if (this.currentIndex < this.questions.length - 1) {
+				this.navigateTo(this.currentIndex + 1);
+				this.tui.requestRender();
+			}
+			return;
+		}
+		if (matchesKey(data, Key.shift("tab"))) {
+			if (this.currentIndex > 0) {
+				this.navigateTo(this.currentIndex - 1);
+				this.tui.requestRender();
+			}
+			return;
+		}
+
+		// Arrow up/down for question navigation when editor is empty
+		// (Editor handles its own cursor navigation when there's content)
+		if (matchesKey(data, Key.up) && this.editor.getText() === "") {
+			if (this.currentIndex > 0) {
+				this.navigateTo(this.currentIndex - 1);
+				this.tui.requestRender();
+				return;
+			}
+		}
+		if (matchesKey(data, Key.down) && this.editor.getText() === "") {
+			if (this.currentIndex < this.questions.length - 1) {
+				this.navigateTo(this.currentIndex + 1);
+				this.tui.requestRender();
+				return;
+			}
+		}
+
+		// Handle Enter ourselves (editor's submit is disabled)
+		// Plain Enter moves to next question or shows confirmation on last question
+		// Shift+Enter adds a newline (handled by editor)
+		if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
+			this.saveCurrentAnswer();
+			if (this.currentIndex < this.questions.length - 1) {
+				this.navigateTo(this.currentIndex + 1);
+			} else {
+				// On last question - show confirmation
+				this.showingConfirmation = true;
+			}
+			this.invalidate();
+			this.tui.requestRender();
+			return;
+		}
+
+		// Pass to editor
+		this.editor.handleInput(data);
+		this.invalidate();
+		this.tui.requestRender();
+	}
+
+	render(width: number): string[] {
+		if (this.cachedLines && this.cachedWidth === width) {
+			return this.cachedLines;
+		}
+
+		const lines: string[] = [];
+		const boxWidth = Math.min(width - 4, 120); // Allow wider box
+		const contentWidth = boxWidth - 4; // 2 chars padding on each side
+
+		// Helper to create horizontal lines (dim the whole thing at once)
+		const horizontalLine = (count: number) => "─".repeat(count);
+
+		// Helper to create a box line
+		const boxLine = (content: string, leftPad: number = 2): string => {
+			const paddedContent = " ".repeat(leftPad) + content;
+			const contentLen = visibleWidth(paddedContent);
+			const rightPad = Math.max(0, boxWidth - contentLen - 2);
+			return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
+		};
+
+		const emptyBoxLine = (): string => {
+			return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
+		};
+
+		const padToWidth = (line: string): string => {
+			const len = visibleWidth(line);
+			return line + " ".repeat(Math.max(0, width - len));
+		};
+
+		// Title
+		lines.push(padToWidth(this.dim("â•­" + horizontalLine(boxWidth - 2) + "â•®")));
+		const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
+		lines.push(padToWidth(boxLine(title)));
+		lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
+
+		// Progress indicator
+		const progressParts: string[] = [];
+		for (let i = 0; i < this.questions.length; i++) {
+			const answered = (this.answers[i]?.trim() || "").length > 0;
+			const current = i === this.currentIndex;
+			if (current) {
+				progressParts.push(this.cyan("â—Ź"));
+			} else if (answered) {
+				progressParts.push(this.green("â—Ź"));
+			} else {
+				progressParts.push(this.dim("â—‹"));
+			}
+		}
+		lines.push(padToWidth(boxLine(progressParts.join(" "))));
+		lines.push(padToWidth(emptyBoxLine()));
+
+		// Current question
+		const q = this.questions[this.currentIndex];
+		const questionText = `${this.bold("Q:")} ${q.question}`;
+		const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
+		for (const line of wrappedQuestion) {
+			lines.push(padToWidth(boxLine(line)));
+		}
+
+		// Context if present
+		if (q.context) {
+			lines.push(padToWidth(emptyBoxLine()));
+			const contextText = this.gray(`> ${q.context}`);
+			const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
+			for (const line of wrappedContext) {
+				lines.push(padToWidth(boxLine(line)));
+			}
+		}
+
+		lines.push(padToWidth(emptyBoxLine()));
+
+		// Render the editor component (multi-line input) with padding
+		// Skip the first and last lines (editor's own border lines)
+		const answerPrefix = this.bold("A: ");
+		const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
+		const editorLines = this.editor.render(editorWidth);
+		for (let i = 1; i < editorLines.length - 1; i++) {
+			if (i === 1) {
+				// First content line gets the "A: " prefix
+				lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
+			} else {
+				// Subsequent lines get padding to align with the first line
+				lines.push(padToWidth(boxLine("   " + editorLines[i])));
+			}
+		}
+
+		// Notes section
+		if (this.notesMode) {
+			lines.push(padToWidth(emptyBoxLine()));
+			const notesLabel = `${this.cyan("✎")} ${this.bold("Note:")} ${this.dim("(Enter to save, Esc to discard)")}`;
+			lines.push(padToWidth(boxLine(notesLabel)));
+			const notesEditorWidth = contentWidth - 4;
+			const notesEditorLines = this.notesEditor.render(notesEditorWidth);
+			for (let i = 1; i < notesEditorLines.length - 1; i++) {
+				lines.push(padToWidth(boxLine("  " + notesEditorLines[i])));
+			}
+		} else if (this.notesText) {
+			lines.push(padToWidth(emptyBoxLine()));
+			const savedNote = `${this.cyan("✎")} ${this.gray(this.notesText)}`;
+			const wrappedNote = wrapTextWithAnsi(savedNote, contentWidth);
+			for (const line of wrappedNote) {
+				lines.push(padToWidth(boxLine(line)));
+			}
+			lines.push(padToWidth(boxLine(this.dim("Alt+N to edit note"))));
+		} else {
+			lines.push(padToWidth(emptyBoxLine()));
+			lines.push(padToWidth(boxLine(this.dim("Alt+N to add a note"))));
+		}
+
+		lines.push(padToWidth(emptyBoxLine()));
+
+		// Confirmation dialog or footer with controls
+		if (this.showingConfirmation) {
+			lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
+			const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
+			lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
+		} else {
+			lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
+			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`;
+			lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
+		}
+		lines.push(padToWidth(this.dim("╰" + horizontalLine(boxWidth - 2) + "╯")));
+
+		this.cachedWidth = width;
+		this.cachedLines = lines;
+		return lines;
+	}
+}
+
+export default function (pi: ExtensionAPI) {
+	const answerHandler = async (ctx: ExtensionContext, instruction?: string) => {
+		if (!ctx.hasUI) {
+			ctx.ui.notify("answer requires interactive mode", "error");
+			return;
+		}
+
+		if (!ctx.model) {
+			ctx.ui.notify("No model selected", "error");
+			return;
+		}
+
+		// Find the last assistant message on the current branch
+		const branch = ctx.sessionManager.getBranch();
+		let lastAssistantText: string | undefined;
+
+		for (let i = branch.length - 1; i >= 0; i--) {
+			const entry = branch[i];
+			if (entry.type === "message") {
+				const msg = entry.message;
+				if ("role" in msg && msg.role === "assistant") {
+					if (msg.stopReason !== "stop") {
+						ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
+						return;
+					}
+					const textParts = msg.content
+						.filter((c): c is { type: "text"; text: string } => c.type === "text")
+						.map((c) => c.text);
+					if (textParts.length > 0) {
+						lastAssistantText = textParts.join("\n");
+						break;
+					}
+				}
+			}
+		}
+
+		if (!lastAssistantText) {
+			ctx.ui.notify("No assistant messages found", "error");
+			return;
+		}
+
+		// Run extraction with loader UI.
+		// The result distinguishes user cancellation from errors so we can
+		// show the right message after the custom UI closes.
+		type ExtractionOutcome =
+			| { kind: "ok"; result: ExtractionResult }
+			| { kind: "cancelled" }
+			| { kind: "error"; message: string };
+
+		const outcome = await ctx.ui.custom<ExtractionOutcome>((tui, theme, _kb, done) => {
+			const extractionModel = resolveExtractionModel(ctx);
+			const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.name}...`);
+			loader.onAbort = () => done({ kind: "cancelled" });
+
+			const tryExtract = async (model: ReturnType<typeof resolveExtractionModel>) => {
+				const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
+				if (!auth.ok) {
+					return { kind: "model_error" as const, model };
+				}
+				const instructionBlock = instruction
+					? `<user_instruction>\n${instruction}\n</user_instruction>\n\nExtract questions from the assistant's message based on the user's instruction above.`
+					: "Extract questions from the assistant's message for the user to fill out.";
+
+				const userMessage: UserMessage = {
+					role: "user",
+					content: [
+						{
+							type: "text",
+							text: `<last_assistant_message>\n${lastAssistantText!}\n</last_assistant_message>\n\n${instructionBlock}`,
+						},
+					],
+					timestamp: Date.now(),
+				};
+
+				const response = await complete(
+					model,
+					{
+						systemPrompt: SYSTEM_PROMPT,
+						messages: [userMessage],
+						tools: [QUESTION_EXTRACTION_TOOL],
+					},
+					{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
+				);
+
+				if (response.stopReason === "aborted") {
+					return { kind: "cancelled" as const };
+				}
+
+				if (response.stopReason === "error" || response.content.length === 0) {
+					return { kind: "model_error" as const, model };
+				}
+
+				const parsed = parseToolCallResult(response);
+				if (!parsed) {
+					return {
+						kind: "parse_error" as const,
+						message: `${model.name} did not call extract_questions tool`,
+					};
+				}
+				return { kind: "ok" as const, result: parsed };
+			};
+
+			const doExtract = async () => {
+				let result = await tryExtract(extractionModel);
+
+				// If the preferred model errored and it's not already the
+				// session model, fall back to the session model.
+				if (result.kind === "model_error" && extractionModel.id !== ctx.model!.id) {
+					ctx.ui.notify(`${extractionModel.name} unavailable, falling back to ${ctx.model!.name}...`, "warning");
+					result = await tryExtract(ctx.model!);
+				}
+
+				switch (result.kind) {
+					case "ok":
+						return done({ kind: "ok", result: result.result });
+					case "cancelled":
+						return done({ kind: "cancelled" });
+					case "model_error":
+						return done({
+							kind: "error",
+							message: `${result.model.name} returned an error with no content`,
+						});
+					case "parse_error":
+						return done({ kind: "error", message: result.message });
+				}
+			};
+
+			doExtract().catch((err) =>
+				done({
+					kind: "error",
+					message: `${err?.message ?? err}`,
+				}),
+			);
+
+			return loader;
+		});
+
+		if (outcome.kind === "cancelled") {
+			ctx.ui.notify("Cancelled", "info");
+			return;
+		}
+		if (outcome.kind === "error") {
+			ctx.ui.notify(`Extraction failed: ${outcome.message}`, "error");
+			return;
+		}
+
+		const extractionResult = outcome.result;
+
+		if (extractionResult.questions.length === 0) {
+			ctx.ui.notify("No questions found in the last message", "info");
+			return;
+		}
+
+		// Show the Q&A component
+		const answersResult = await ctx.ui.custom<string | null>((tui, _theme, _kb, done) => {
+			return new QnAComponent(extractionResult.questions, tui, done);
+		});
+
+		if (answersResult === null) {
+			ctx.ui.notify("Cancelled", "info");
+			return;
+		}
+
+		// Send the answers directly as a message and trigger a turn
+		pi.sendMessage(
+			{
+				customType: "answers",
+				content: "I answered your questions in the following way:\n\n" + answersResult,
+				display: true,
+			},
+			{ triggerTurn: true },
+		);
+	};
+
+	pi.registerCommand("answer", {
+		description:
+			"Extract questions from last assistant message into interactive Q&A. Optional: provide instructions for what to extract.",
+		handler: (args, ctx) => answerHandler(ctx, args?.trim() || undefined),
+	});
+
+	pi.registerShortcut("ctrl+.", {
+		description: "Extract and answer questions",
+		handler: answerHandler,
+	});
+}