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, fallback: NonNullable<ExtensionContext["model"]>) {
192 const available = ctx.modelRegistry.getAvailable();
193 const preferred = available.find((m) => m.id === PREFERRED_EXTRACTION_MODEL_ID);
194 return preferred ?? fallback;
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 selectedPrefix: this.cyan,
276 selectedText: (s: string) => `\x1b[44m${s}\x1b[0m`,
277 description: this.gray,
278 scrollInfo: this.dim,
279 noMatch: this.dim,
280 },
281 };
282
283 this.editor = new Editor(tui, editorTheme);
284 // Disable the editor's built-in submit (which clears the editor)
285 // We'll handle Enter ourselves to preserve the text
286 this.editor.disableSubmit = true;
287 this.editor.onChange = () => {
288 this.invalidate();
289 this.tui.requestRender();
290 };
291
292 this.notesEditor = new Editor(tui, editorTheme);
293 this.notesEditor.onSubmit = (value) => {
294 this.notesText = value.trim();
295 this.notesMode = false;
296 this.invalidate();
297 this.tui.requestRender();
298 };
299 }
300
301 private saveCurrentAnswer(): void {
302 this.answers[this.currentIndex] = this.editor.getText();
303 }
304
305 private navigateTo(index: number): void {
306 if (index < 0 || index >= this.questions.length) return;
307 this.saveCurrentAnswer();
308 this.currentIndex = index;
309 this.editor.setText(this.answers[index] || "");
310 this.invalidate();
311 }
312
313 private submit(): void {
314 this.saveCurrentAnswer();
315
316 // Build the response text
317 const parts: string[] = [];
318 parts.push(`<qna>`);
319 for (let i = 0; i < this.questions.length; i++) {
320 const q = this.questions[i];
321 const a = this.answers[i]?.trim() || "(no answer)";
322 parts.push(`<q n="${i}">${q.question}</q>`);
323 parts.push(`<a n="${i}">${a}</a>`);
324 }
325 parts.push(`</qna>`);
326 if (this.notesText) {
327 parts.push(`\n<note>${this.notesText}</note>`);
328 }
329
330 this.onDone(parts.join("\n").trim());
331 }
332
333 private cancel(): void {
334 this.onDone(null);
335 }
336
337 invalidate(): void {
338 this.cachedWidth = undefined;
339 this.cachedLines = undefined;
340 }
341
342 handleInput(data: string): void {
343 // Notes mode: route to notes editor
344 if (this.notesMode) {
345 if (matchesKey(data, Key.escape)) {
346 this.notesMode = false;
347 this.notesEditor.setText(this.notesText);
348 this.invalidate();
349 this.tui.requestRender();
350 return;
351 }
352 this.notesEditor.handleInput(data);
353 this.invalidate();
354 this.tui.requestRender();
355 return;
356 }
357
358 // Handle confirmation dialog
359 if (this.showingConfirmation) {
360 if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
361 this.submit();
362 return;
363 }
364 if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") {
365 this.showingConfirmation = false;
366 this.invalidate();
367 this.tui.requestRender();
368 return;
369 }
370 return;
371 }
372
373 // Global navigation and commands
374 if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
375 this.cancel();
376 return;
377 }
378
379 // Activate notes editor (available when not typing an answer)
380 if (matchesKey(data, Key.alt("n"))) {
381 this.notesMode = true;
382 this.notesEditor.setText(this.notesText);
383 this.invalidate();
384 this.tui.requestRender();
385 return;
386 }
387
388 // Tab / Shift+Tab for navigation
389 if (matchesKey(data, Key.tab)) {
390 if (this.currentIndex < this.questions.length - 1) {
391 this.navigateTo(this.currentIndex + 1);
392 this.tui.requestRender();
393 }
394 return;
395 }
396 if (matchesKey(data, Key.shift("tab"))) {
397 if (this.currentIndex > 0) {
398 this.navigateTo(this.currentIndex - 1);
399 this.tui.requestRender();
400 }
401 return;
402 }
403
404 // Arrow up/down for question navigation when editor is empty
405 // (Editor handles its own cursor navigation when there's content)
406 if (matchesKey(data, Key.up) && this.editor.getText() === "") {
407 if (this.currentIndex > 0) {
408 this.navigateTo(this.currentIndex - 1);
409 this.tui.requestRender();
410 return;
411 }
412 }
413 if (matchesKey(data, Key.down) && this.editor.getText() === "") {
414 if (this.currentIndex < this.questions.length - 1) {
415 this.navigateTo(this.currentIndex + 1);
416 this.tui.requestRender();
417 return;
418 }
419 }
420
421 // Handle Enter ourselves (editor's submit is disabled)
422 // Plain Enter moves to next question or shows confirmation on last question
423 // Shift+Enter adds a newline (handled by editor)
424 if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
425 this.saveCurrentAnswer();
426 if (this.currentIndex < this.questions.length - 1) {
427 this.navigateTo(this.currentIndex + 1);
428 } else {
429 // On last question - show confirmation
430 this.showingConfirmation = true;
431 }
432 this.invalidate();
433 this.tui.requestRender();
434 return;
435 }
436
437 // Pass to editor
438 this.editor.handleInput(data);
439 this.invalidate();
440 this.tui.requestRender();
441 }
442
443 render(width: number): string[] {
444 if (this.cachedLines && this.cachedWidth === width) {
445 return this.cachedLines;
446 }
447
448 const lines: string[] = [];
449 const boxWidth = Math.min(width - 4, 120); // Allow wider box
450 const contentWidth = boxWidth - 4; // 2 chars padding on each side
451
452 // Helper to create horizontal lines (dim the whole thing at once)
453 const horizontalLine = (count: number) => "─".repeat(count);
454
455 // Helper to create a box line
456 const boxLine = (content: string, leftPad: number = 2): string => {
457 const paddedContent = " ".repeat(leftPad) + content;
458 const contentLen = visibleWidth(paddedContent);
459 const rightPad = Math.max(0, boxWidth - contentLen - 2);
460 return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
461 };
462
463 const emptyBoxLine = (): string => {
464 return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
465 };
466
467 const padToWidth = (line: string): string => {
468 const len = visibleWidth(line);
469 return line + " ".repeat(Math.max(0, width - len));
470 };
471
472 // Title
473 lines.push(padToWidth(this.dim(`â•${horizontalLine(boxWidth - 2)}â•®`)));
474 const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
475 lines.push(padToWidth(boxLine(title)));
476 lines.push(padToWidth(this.dim(`├${horizontalLine(boxWidth - 2)}┤`)));
477
478 // Progress indicator
479 const progressParts: string[] = [];
480 for (let i = 0; i < this.questions.length; i++) {
481 const answered = (this.answers[i]?.trim() || "").length > 0;
482 const current = i === this.currentIndex;
483 if (current) {
484 progressParts.push(this.cyan("â—Ź"));
485 } else if (answered) {
486 progressParts.push(this.green("â—Ź"));
487 } else {
488 progressParts.push(this.dim("â—‹"));
489 }
490 }
491 lines.push(padToWidth(boxLine(progressParts.join(" "))));
492 lines.push(padToWidth(emptyBoxLine()));
493
494 // Current question
495 const q = this.questions[this.currentIndex];
496 const questionText = `${this.bold("Q:")} ${q.question}`;
497 const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
498 for (const line of wrappedQuestion) {
499 lines.push(padToWidth(boxLine(line)));
500 }
501
502 // Context if present
503 if (q.context) {
504 lines.push(padToWidth(emptyBoxLine()));
505 const contextText = this.gray(`> ${q.context}`);
506 const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
507 for (const line of wrappedContext) {
508 lines.push(padToWidth(boxLine(line)));
509 }
510 }
511
512 lines.push(padToWidth(emptyBoxLine()));
513
514 // Render the editor component (multi-line input) with padding
515 // Skip the first and last lines (editor's own border lines)
516 const answerPrefix = this.bold("A: ");
517 const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
518 const editorLines = this.editor.render(editorWidth);
519 for (let i = 1; i < editorLines.length - 1; i++) {
520 if (i === 1) {
521 // First content line gets the "A: " prefix
522 lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
523 } else {
524 // Subsequent lines get padding to align with the first line
525 lines.push(padToWidth(boxLine(` ${editorLines[i]}`)));
526 }
527 }
528
529 // Notes section
530 if (this.notesMode) {
531 lines.push(padToWidth(emptyBoxLine()));
532 const notesLabel = `${this.cyan("✎")} ${this.bold("Note:")} ${this.dim("(Enter to save, Esc to discard)")}`;
533 lines.push(padToWidth(boxLine(notesLabel)));
534 const notesEditorWidth = contentWidth - 4;
535 const notesEditorLines = this.notesEditor.render(notesEditorWidth);
536 for (let i = 1; i < notesEditorLines.length - 1; i++) {
537 lines.push(padToWidth(boxLine(` ${notesEditorLines[i]}`)));
538 }
539 } else if (this.notesText) {
540 lines.push(padToWidth(emptyBoxLine()));
541 const savedNote = `${this.cyan("✎")} ${this.gray(this.notesText)}`;
542 const wrappedNote = wrapTextWithAnsi(savedNote, contentWidth);
543 for (const line of wrappedNote) {
544 lines.push(padToWidth(boxLine(line)));
545 }
546 lines.push(padToWidth(boxLine(this.dim("Alt+N to edit note"))));
547 } else {
548 lines.push(padToWidth(emptyBoxLine()));
549 lines.push(padToWidth(boxLine(this.dim("Alt+N to add a note"))));
550 }
551
552 lines.push(padToWidth(emptyBoxLine()));
553
554 // Confirmation dialog or footer with controls
555 if (this.showingConfirmation) {
556 lines.push(padToWidth(this.dim(`├${horizontalLine(boxWidth - 2)}┤`)));
557 const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
558 lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
559 } else {
560 lines.push(padToWidth(this.dim(`├${horizontalLine(boxWidth - 2)}┤`)));
561 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`;
562 lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
563 }
564 lines.push(padToWidth(this.dim(`╰${horizontalLine(boxWidth - 2)}╯`)));
565
566 this.cachedWidth = width;
567 this.cachedLines = lines;
568 return lines;
569 }
570}
571
572export default function (pi: ExtensionAPI) {
573 const answerHandler = async (ctx: ExtensionContext, instruction?: string) => {
574 if (!ctx.hasUI) {
575 ctx.ui.notify("answer requires interactive mode", "error");
576 return;
577 }
578
579 if (!ctx.model) {
580 ctx.ui.notify("No model selected", "error");
581 return;
582 }
583
584 // Find the last assistant message on the current branch
585 const branch = ctx.sessionManager.getBranch();
586 let lastAssistantText: string | undefined;
587
588 for (let i = branch.length - 1; i >= 0; i--) {
589 const entry = branch[i];
590 if (entry.type === "message") {
591 const msg = entry.message;
592 if ("role" in msg && msg.role === "assistant") {
593 if (msg.stopReason !== "stop") {
594 ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
595 return;
596 }
597 const textParts = msg.content
598 .filter((c): c is { type: "text"; text: string } => c.type === "text")
599 .map((c) => c.text);
600 if (textParts.length > 0) {
601 lastAssistantText = textParts.join("\n");
602 break;
603 }
604 }
605 }
606 }
607
608 if (!lastAssistantText) {
609 ctx.ui.notify("No assistant messages found", "error");
610 return;
611 }
612
613 // Capture narrowed values so closures can use them without non-null assertions.
614 const sessionModel = ctx.model;
615 const assistantText = lastAssistantText;
616
617 // Run extraction with loader UI.
618 // The result distinguishes user cancellation from errors so we can
619 // show the right message after the custom UI closes.
620 type ExtractionOutcome =
621 | { kind: "ok"; result: ExtractionResult }
622 | { kind: "cancelled" }
623 | { kind: "error"; message: string };
624
625 const outcome = await ctx.ui.custom<ExtractionOutcome>((tui, theme, _kb, done) => {
626 const extractionModel = resolveExtractionModel(ctx, sessionModel);
627 const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.name}...`);
628 loader.onAbort = () => done({ kind: "cancelled" });
629
630 const tryExtract = async (model: ReturnType<typeof resolveExtractionModel>) => {
631 const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
632 if (!auth.ok) {
633 return { kind: "model_error" as const, model };
634 }
635 const instructionBlock = instruction
636 ? `<user_instruction>\n${instruction}\n</user_instruction>\n\nExtract questions from the assistant's message based on the user's instruction above.`
637 : "Extract questions from the assistant's message for the user to fill out.";
638
639 const userMessage: UserMessage = {
640 role: "user",
641 content: [
642 {
643 type: "text",
644 text: `<last_assistant_message>\n${assistantText}\n</last_assistant_message>\n\n${instructionBlock}`,
645 },
646 ],
647 timestamp: Date.now(),
648 };
649
650 const response = await complete(
651 model,
652 {
653 systemPrompt: SYSTEM_PROMPT,
654 messages: [userMessage],
655 tools: [QUESTION_EXTRACTION_TOOL],
656 },
657 { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
658 );
659
660 if (response.stopReason === "aborted") {
661 return { kind: "cancelled" as const };
662 }
663
664 if (response.stopReason === "error" || response.content.length === 0) {
665 return { kind: "model_error" as const, model };
666 }
667
668 const parsed = parseToolCallResult(response);
669 if (!parsed) {
670 return {
671 kind: "parse_error" as const,
672 message: `${model.name} did not call extract_questions tool`,
673 };
674 }
675 return { kind: "ok" as const, result: parsed };
676 };
677
678 const doExtract = async () => {
679 let result = await tryExtract(extractionModel);
680
681 // If the preferred model errored and it's not already the
682 // session model, fall back to the session model.
683 if (result.kind === "model_error" && extractionModel.id !== sessionModel.id) {
684 ctx.ui.notify(`${extractionModel.name} unavailable, falling back to ${sessionModel.name}...`, "warning");
685 result = await tryExtract(sessionModel);
686 }
687
688 switch (result.kind) {
689 case "ok":
690 return done({ kind: "ok", result: result.result });
691 case "cancelled":
692 return done({ kind: "cancelled" });
693 case "model_error":
694 return done({
695 kind: "error",
696 message: `${result.model.name} returned an error with no content`,
697 });
698 case "parse_error":
699 return done({ kind: "error", message: result.message });
700 }
701 };
702
703 doExtract().catch((err) =>
704 done({
705 kind: "error",
706 message: `${err?.message ?? err}`,
707 }),
708 );
709
710 return loader;
711 });
712
713 if (outcome.kind === "cancelled") {
714 ctx.ui.notify("Cancelled", "info");
715 return;
716 }
717 if (outcome.kind === "error") {
718 ctx.ui.notify(`Extraction failed: ${outcome.message}`, "error");
719 return;
720 }
721
722 const extractionResult = outcome.result;
723
724 if (extractionResult.questions.length === 0) {
725 ctx.ui.notify("No questions found in the last message", "info");
726 return;
727 }
728
729 // Show the Q&A component
730 const answersResult = await ctx.ui.custom<string | null>((tui, _theme, _kb, done) => {
731 return new QnAComponent(extractionResult.questions, tui, done);
732 });
733
734 if (answersResult === null) {
735 ctx.ui.notify("Cancelled", "info");
736 return;
737 }
738
739 // Send the answers directly as a message and trigger a turn
740 pi.sendMessage(
741 {
742 customType: "answers",
743 content: `I answered your questions in the following way:\n\n${answersResult}`,
744 display: true,
745 },
746 { triggerTurn: true },
747 );
748 };
749
750 pi.registerCommand("answer", {
751 description:
752 "Extract questions from last assistant message into interactive Q&A. Optional: provide instructions for what to extract.",
753 handler: (args, ctx) => answerHandler(ctx, args?.trim() || undefined),
754 });
755
756 pi.registerShortcut("ctrl+.", {
757 description: "Extract and answer questions",
758 handler: answerHandler,
759 });
760}