Commit ef605a1124e4

Vincent Demeester <vincent@sbr.pm>
2026-02-10 17:04:31
feat: add answer pi extension for interactive Q&A extraction
Added extension from mitsuhiko/agent-stuff that extracts questions from assistant messages and presents an interactive TUI for answering them. Adapted model selection to prefer available lightweight models (Gemini, Copilot) over the original OpenAI Codex/Haiku defaults.
1 parent a0ee504
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/answer.ts
@@ -0,0 +1,535 @@
+/**
+ * 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
+ *
+ * Original: https://github.com/mitsuhiko/agent-stuff/blob/main/pi-extensions/answer.ts
+ */
+
+import { complete, type Model, type Api, 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";
+
+// Structured output format for question extraction
+interface ExtractedQuestion {
+	question: string;
+	context?: string;
+}
+
+interface ExtractionResult {
+	questions: ExtractedQuestion[];
+}
+
+const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering.
+
+Output a JSON object with this structure:
+{
+  "questions": [
+    {
+      "question": "The question text",
+      "context": "Optional context that helps answer the question"
+    }
+  ]
+}
+
+Rules:
+- Extract all questions that require user input
+- Keep questions in the order they appeared
+- Be concise with question text
+- Include context only when it provides essential information for answering
+- If no questions are found, return {"questions": []}
+
+Example output:
+{
+  "questions": [
+    {
+      "question": "What is your preferred database?",
+      "context": "We can only configure MySQL and PostgreSQL because of what is implemented."
+    },
+    {
+      "question": "Should we use TypeScript or JavaScript?"
+    }
+  ]
+}`;
+
+/**
+ * Prefer a lightweight model for extraction, falling back to the current model.
+ * Tries models in order of preference (cheap/fast first).
+ */
+async function selectExtractionModel(
+	currentModel: Model<Api>,
+	modelRegistry: {
+		find: (provider: string, modelId: string) => Model<Api> | undefined;
+		getApiKey: (model: Model<Api>) => Promise<string | undefined>;
+	},
+): Promise<Model<Api>> {
+	const candidates = [
+		{ provider: "google", id: "gemini-2.5-pro" },
+		{ provider: "google", id: "gemini-2.0-flash" },
+		{ provider: "google", id: "gemini-1.5-flash" },
+		{ provider: "github-copilot", id: "gpt-5-mini" },
+		{ provider: "github-copilot", id: "gpt-4o" },
+		{ provider: "openai-codex", id: "gpt-5.1-codex-mini" },
+		{ provider: "anthropic", id: "claude-haiku-4-5" },
+		{ provider: "anthropic", id: "claude-3-5-haiku" },
+	];
+
+	for (const { provider, id } of candidates) {
+		const model = modelRegistry.find(provider, id);
+		if (model) {
+			const apiKey = await modelRegistry.getApiKey(model);
+			if (apiKey) {
+				return model;
+			}
+		}
+	}
+
+	return currentModel;
+}
+
+/**
+ * Parse the JSON response from the LLM
+ */
+function parseExtractionResult(text: string): ExtractionResult | null {
+	try {
+		// Try to find JSON in the response (it might be wrapped in markdown code blocks)
+		let jsonStr = text;
+
+		// Remove markdown code block if present
+		const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
+		if (jsonMatch) {
+			jsonStr = jsonMatch[1].trim();
+		}
+
+		const parsed = JSON.parse(jsonStr);
+		if (parsed && Array.isArray(parsed.questions)) {
+			return parsed as ExtractionResult;
+		}
+		return null;
+	} catch {
+		return null;
+	}
+}
+
+/**
+ * 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;
+
+	// 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();
+		};
+	}
+
+	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[] = [];
+		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: ${q.question}`);
+			if (q.context) {
+				parts.push(`> ${q.context}`);
+			}
+			parts.push(`A: ${a}`);
+			parts.push("");
+		}
+
+		this.onDone(parts.join("\n").trim());
+	}
+
+	private cancel(): void {
+		this.onDone(null);
+	}
+
+	invalidate(): void {
+		this.cachedWidth = undefined;
+		this.cachedLines = undefined;
+	}
+
+	handleInput(data: string): void {
+		// 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;
+		}
+
+		// 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])));
+			}
+		}
+
+		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("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) => {
+			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;
+			}
+
+			// Select the best model for extraction (prefer Codex mini, then haiku)
+			const extractionModel = await selectExtractionModel(ctx.model, ctx.modelRegistry);
+
+			// Run extraction with loader UI
+			const extractionResult = await ctx.ui.custom<ExtractionResult | null>((tui, theme, _kb, done) => {
+				const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.id}...`);
+				loader.onAbort = () => done(null);
+
+				const doExtract = async () => {
+					const apiKey = await ctx.modelRegistry.getApiKey(extractionModel);
+					const userMessage: UserMessage = {
+						role: "user",
+						content: [{ type: "text", text: lastAssistantText! }],
+						timestamp: Date.now(),
+					};
+
+					const response = await complete(
+						extractionModel,
+						{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
+						{ apiKey, signal: loader.signal },
+					);
+
+					if (response.stopReason === "aborted") {
+						return null;
+					}
+
+					const responseText = response.content
+						.filter((c): c is { type: "text"; text: string } => c.type === "text")
+						.map((c) => c.text)
+						.join("\n");
+
+					return parseExtractionResult(responseText);
+				};
+
+				doExtract()
+					.then(done)
+					.catch(() => done(null));
+
+				return loader;
+			});
+
+			if (extractionResult === null) {
+				ctx.ui.notify("Cancelled", "info");
+				return;
+			}
+
+			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",
+		handler: (_args, ctx) => answerHandler(ctx),
+	});
+
+	pi.registerShortcut("ctrl+.", {
+		description: "Extract and answer questions",
+		handler: answerHandler,
+	});
+}