QnAComponent.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
  6import {
  7	type Component,
  8	Editor,
  9	type EditorTheme,
 10	Key,
 11	matchesKey,
 12	truncateToWidth,
 13	type TUI,
 14	visibleWidth,
 15	wrapTextWithAnsi,
 16} from "@mariozechner/pi-tui";
 17import { escapeXml } from "./extract.js";
 18import type { ExtractedQuestion } from "./prompt.js";
 19
 20/**
 21 * Interactive Q&A component for answering extracted questions
 22 */
 23export class QnAComponent implements Component {
 24	private questions: ExtractedQuestion[];
 25	private answers: string[];
 26	private currentIndex: number = 0;
 27	private editor: Editor;
 28	private tui: TUI;
 29	private onDone: (result: string | null) => void;
 30	private showingConfirmation: boolean = false;
 31	private notesMode: boolean = false;
 32	private notesText: string = "";
 33	private notesEditor: Editor;
 34
 35	// Cache
 36	private cachedWidth?: number;
 37	private cachedLines?: string[];
 38
 39	// Colors - using proper reset sequences
 40	private dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
 41	private bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
 42	private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
 43	private green = (s: string) => `\x1b[32m${s}\x1b[0m`;
 44	private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
 45	private gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
 46
 47	constructor(questions: ExtractedQuestion[], tui: TUI, onDone: (result: string | null) => void) {
 48		this.questions = questions;
 49		this.answers = questions.map(() => "");
 50		this.tui = tui;
 51		this.onDone = onDone;
 52
 53		// Create a minimal theme for the editor
 54		const editorTheme: EditorTheme = {
 55			borderColor: this.dim,
 56			selectList: {
 57				selectedPrefix: this.cyan,
 58				selectedText: (s: string) => `\x1b[44m${s}\x1b[0m`,
 59				description: this.gray,
 60				scrollInfo: this.dim,
 61				noMatch: this.dim,
 62			},
 63		};
 64
 65		this.editor = new Editor(tui, editorTheme);
 66		// Disable the editor's built-in submit (which clears the editor)
 67		// We'll handle Enter ourselves to preserve the text
 68		this.editor.disableSubmit = true;
 69		this.editor.onChange = () => {
 70			this.invalidate();
 71			this.tui.requestRender();
 72		};
 73
 74		this.notesEditor = new Editor(tui, editorTheme);
 75		this.notesEditor.onSubmit = (value) => {
 76			this.notesText = value.trim();
 77			this.notesMode = false;
 78			this.invalidate();
 79			this.tui.requestRender();
 80		};
 81	}
 82
 83	private saveCurrentAnswer(): void {
 84		this.answers[this.currentIndex] = this.editor.getText();
 85	}
 86
 87	private navigateTo(index: number): void {
 88		if (index < 0 || index >= this.questions.length) return;
 89		this.saveCurrentAnswer();
 90		this.currentIndex = index;
 91		this.editor.setText(this.answers[index] || "");
 92		this.invalidate();
 93	}
 94
 95	private submit(): void {
 96		this.saveCurrentAnswer();
 97
 98		// Build the response text
 99		const parts: string[] = [];
100		parts.push("<qna>");
101		for (let i = 0; i < this.questions.length; i++) {
102			const q = this.questions[i];
103			const a = this.answers[i]?.trim() || "(no answer)";
104			parts.push(`<q n="${i}">${escapeXml(q.question)}</q>`);
105			parts.push(`<a n="${i}">${escapeXml(a)}</a>`);
106		}
107		parts.push("</qna>");
108		if (this.notesText) {
109			parts.push(`\n<note>${escapeXml(this.notesText)}</note>`);
110		}
111
112		this.onDone(parts.join("\n").trim());
113	}
114
115	private cancel(): void {
116		this.onDone(null);
117	}
118
119	invalidate(): void {
120		this.cachedWidth = undefined;
121		this.cachedLines = undefined;
122	}
123
124	handleInput(data: string): void {
125		// Notes mode: route to notes editor
126		if (this.notesMode) {
127			if (matchesKey(data, Key.escape)) {
128				this.notesMode = false;
129				this.notesEditor.setText(this.notesText);
130				this.invalidate();
131				this.tui.requestRender();
132				return;
133			}
134			this.notesEditor.handleInput(data);
135			this.invalidate();
136			this.tui.requestRender();
137			return;
138		}
139
140		// Handle confirmation dialog
141		if (this.showingConfirmation) {
142			if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
143				this.submit();
144				return;
145			}
146			if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") {
147				this.showingConfirmation = false;
148				this.invalidate();
149				this.tui.requestRender();
150				return;
151			}
152			return;
153		}
154
155		// Global navigation and commands
156		if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
157			this.cancel();
158			return;
159		}
160
161		// Activate notes editor (available when not typing an answer)
162		if (matchesKey(data, Key.alt("n"))) {
163			this.notesMode = true;
164			this.notesEditor.setText(this.notesText);
165			this.invalidate();
166			this.tui.requestRender();
167			return;
168		}
169
170		// Tab / Shift+Tab for navigation
171		if (matchesKey(data, Key.tab)) {
172			if (this.currentIndex < this.questions.length - 1) {
173				this.navigateTo(this.currentIndex + 1);
174				this.tui.requestRender();
175			}
176			return;
177		}
178		if (matchesKey(data, Key.shift("tab"))) {
179			if (this.currentIndex > 0) {
180				this.navigateTo(this.currentIndex - 1);
181				this.tui.requestRender();
182			}
183			return;
184		}
185
186		// Arrow up/down for question navigation when editor is empty
187		// (Editor handles its own cursor navigation when there's content)
188		if (matchesKey(data, Key.up) && this.editor.getText() === "") {
189			if (this.currentIndex > 0) {
190				this.navigateTo(this.currentIndex - 1);
191				this.tui.requestRender();
192				return;
193			}
194		}
195		if (matchesKey(data, Key.down) && this.editor.getText() === "") {
196			if (this.currentIndex < this.questions.length - 1) {
197				this.navigateTo(this.currentIndex + 1);
198				this.tui.requestRender();
199				return;
200			}
201		}
202
203		// Handle Enter ourselves (editor's submit is disabled)
204		// Plain Enter moves to next question or shows confirmation on last question
205		// Shift+Enter adds a newline (handled by editor)
206		if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
207			this.saveCurrentAnswer();
208			if (this.currentIndex < this.questions.length - 1) {
209				this.navigateTo(this.currentIndex + 1);
210			} else {
211				// On last question - show confirmation
212				this.showingConfirmation = true;
213			}
214			this.invalidate();
215			this.tui.requestRender();
216			return;
217		}
218
219		// Pass to editor
220		this.editor.handleInput(data);
221		this.invalidate();
222		this.tui.requestRender();
223	}
224
225	render(width: number): string[] {
226		if (this.cachedLines && this.cachedWidth === width) {
227			return this.cachedLines;
228		}
229
230		const lines: string[] = [];
231		const boxWidth = Math.min(width - 4, 120); // Allow wider box
232		const contentWidth = boxWidth - 4; // 2 chars padding on each side
233
234		// Helper to create horizontal lines (dim the whole thing at once)
235		const horizontalLine = (count: number) => "─".repeat(count);
236
237		// Helper to create a box line
238		const boxLine = (content: string, leftPad: number = 2): string => {
239			const paddedContent = " ".repeat(leftPad) + content;
240			const contentLen = visibleWidth(paddedContent);
241			const rightPad = Math.max(0, boxWidth - contentLen - 2);
242			return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
243		};
244
245		const emptyBoxLine = (): string => {
246			return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
247		};
248
249		const padToWidth = (line: string): string => {
250			const len = visibleWidth(line);
251			return line + " ".repeat(Math.max(0, width - len));
252		};
253
254		// Title
255		lines.push(padToWidth(this.dim(`${horizontalLine(boxWidth - 2)}`)));
256		const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
257		lines.push(padToWidth(boxLine(title)));
258		lines.push(padToWidth(this.dim(`${horizontalLine(boxWidth - 2)}`)));
259
260		// Progress indicator
261		const progressParts: string[] = [];
262		for (let i = 0; i < this.questions.length; i++) {
263			const answered = (this.answers[i]?.trim() || "").length > 0;
264			const current = i === this.currentIndex;
265			if (current) {
266				progressParts.push(this.cyan("●"));
267			} else if (answered) {
268				progressParts.push(this.green("●"));
269			} else {
270				progressParts.push(this.dim("○"));
271			}
272		}
273		lines.push(padToWidth(boxLine(progressParts.join(" "))));
274		lines.push(padToWidth(emptyBoxLine()));
275
276		// Current question
277		const q = this.questions[this.currentIndex];
278		const questionText = `${this.bold("Q:")} ${q.question}`;
279		const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
280		for (const line of wrappedQuestion) {
281			lines.push(padToWidth(boxLine(line)));
282		}
283
284		// Context if present
285		if (q.context) {
286			lines.push(padToWidth(emptyBoxLine()));
287			const contextText = this.gray(`> ${q.context}`);
288			const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
289			for (const line of wrappedContext) {
290				lines.push(padToWidth(boxLine(line)));
291			}
292		}
293
294		lines.push(padToWidth(emptyBoxLine()));
295
296		// Render the editor component (multi-line input) with padding
297		// Skip the first and last lines (editor's own border lines)
298		const answerPrefix = this.bold("A: ");
299		const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
300		const editorLines = this.editor.render(editorWidth);
301		for (let i = 1; i < editorLines.length - 1; i++) {
302			if (i === 1) {
303				// First content line gets the "A: " prefix
304				lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
305			} else {
306				// Subsequent lines get padding to align with the first line
307				lines.push(padToWidth(boxLine(`   ${editorLines[i]}`)));
308			}
309		}
310
311		// Notes section
312		if (this.notesMode) {
313			lines.push(padToWidth(emptyBoxLine()));
314			const notesLabel = `${this.cyan("✎")} ${this.bold("Note:")} ${this.dim("(Enter to save, Esc to discard)")}`;
315			lines.push(padToWidth(boxLine(notesLabel)));
316			const notesEditorWidth = contentWidth - 4;
317			const notesEditorLines = this.notesEditor.render(notesEditorWidth);
318			for (let i = 1; i < notesEditorLines.length - 1; i++) {
319				lines.push(padToWidth(boxLine(`  ${notesEditorLines[i]}`)));
320			}
321		} else if (this.notesText) {
322			lines.push(padToWidth(emptyBoxLine()));
323			const savedNote = `${this.cyan("✎")} ${this.gray(this.notesText)}`;
324			const wrappedNote = wrapTextWithAnsi(savedNote, contentWidth);
325			for (const line of wrappedNote) {
326				lines.push(padToWidth(boxLine(line)));
327			}
328			lines.push(padToWidth(boxLine(this.dim("Alt+N to edit note"))));
329		} else {
330			lines.push(padToWidth(emptyBoxLine()));
331			lines.push(padToWidth(boxLine(this.dim("Alt+N to add a note"))));
332		}
333
334		lines.push(padToWidth(emptyBoxLine()));
335
336		// Confirmation dialog or footer with controls
337		if (this.showingConfirmation) {
338			lines.push(padToWidth(this.dim(`${horizontalLine(boxWidth - 2)}`)));
339			const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
340			lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
341		} else {
342			lines.push(padToWidth(this.dim(`${horizontalLine(boxWidth - 2)}`)));
343			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`;
344			lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
345		}
346		lines.push(padToWidth(this.dim(`${horizontalLine(boxWidth - 2)}`)));
347
348		this.cachedWidth = width;
349		this.cachedLines = lines;
350		return lines;
351	}
352}