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}