import type { Theme } from "@earendil-works/pi-coding-agent"; import { Editor, type Component, type EditorTheme, type Focusable, Key, matchesKey, type TUI, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { orderedAnswers } from "./normalize.ts"; import type { AskUserResult, NormalizedQuestion, QuestionOptionInput, UserAnswer } from "./types.ts"; type InputKind = "text" | "custom"; type Done = (result: AskUserResult) => void; type RenderOption = QuestionOptionInput & { kind: "option" | "custom" | "skip" }; export class AskUserView implements Component, Focusable { private readonly editor: Editor; private readonly answers = new Map(); private readonly abortHandler: () => void; private currentPage = 0; private optionIndex = 0; private inputKind: InputKind | null = null; private validationMessage: string | undefined; private cachedWidth: number | undefined; private cachedLines: string[] | undefined; private completed = false; private _focused = false; constructor( private readonly tui: TUI, private readonly theme: Theme, private readonly questions: NormalizedQuestion[], private readonly done: Done, private readonly signal?: AbortSignal, ) { const editorTheme: EditorTheme = { borderColor: (text) => theme.fg("accent", text), selectList: { selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("muted", text), scrollInfo: (text) => theme.fg("dim", text), noMatch: (text) => theme.fg("warning", text), }, }; this.editor = new Editor(tui, editorTheme); this.editor.onSubmit = (value) => this.submitEditor(value); this.abortHandler = () => this.finish(true); signal?.addEventListener("abort", this.abortHandler, { once: true }); } get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.syncEditorFocus(); } private syncEditorFocus(): void { this.editor.focused = this._focused && this.inputKind !== null; } private refresh(): void { this.cachedWidth = undefined; this.cachedLines = undefined; this.syncEditorFocus(); this.tui.requestRender(); } private currentQuestion(): NormalizedQuestion | undefined { return this.questions[this.currentPage]; } private currentOptions(): RenderOption[] { const question = this.currentQuestion(); if (!question || question.type !== "select") return []; const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" })); if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" }); if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" }); return options; } private allAnswered(): boolean { return this.questions.every((question) => this.answers.has(question.id)); } private setCurrentPage(page: number): void { this.currentPage = page; this.inputKind = null; this.validationMessage = undefined; this.editor.setText(""); this.optionIndex = 0; const question = this.currentQuestion(); const answer = question ? this.answers.get(question.id) : undefined; if (question?.type === "select" && answer) { const options = this.currentOptions(); const selected = options.findIndex((option) => answer.skipped ? option.kind === "skip" : answer.custom ? option.kind === "custom" : option.kind === "option" && option.value === answer.value, ); this.optionIndex = Math.max(0, selected); } this.refresh(); } private movePage(delta: number): void { const pageCount = this.questions.length + 1; this.setCurrentPage((this.currentPage + delta + pageCount) % pageCount); } private enterEditor(kind: InputKind): void { const question = this.currentQuestion(); if (!question) return; const existing = this.answers.get(question.id); this.inputKind = kind; this.validationMessage = undefined; this.editor.setText(existing && (question.type === "text" || existing.custom) ? existing.answer : ""); this.refresh(); } private saveAnswer(question: NormalizedQuestion, answer: Omit): void { this.answers.set(question.id, { id: question.id, question: question.prompt, type: question.type, ...answer, }); } private submitEditor(value: string): void { const question = this.currentQuestion(); if (!question || !this.inputKind) return; const trimmed = value.trim(); if (!trimmed && (question.required || this.inputKind === "custom")) { this.validationMessage = "An answer is required."; this.refresh(); return; } this.saveAnswer(question, { answer: trimmed, value: trimmed, label: trimmed, custom: this.inputKind === "custom", skipped: !trimmed, }); this.inputKind = null; this.editor.setText(""); this.advanceAfterAnswer(); } private selectCurrentOption(): void { const question = this.currentQuestion(); const option = this.currentOptions()[this.optionIndex]; if (!question || !option) return; if (option.kind === "custom") { this.enterEditor("custom"); return; } if (option.kind === "skip") { this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true }); } else { this.saveAnswer(question, { answer: option.label, value: option.value, label: option.label, custom: false, skipped: false, }); } this.advanceAfterAnswer(); } private advanceAfterAnswer(): void { if (this.questions.length === 1) { this.finish(false); return; } this.setCurrentPage(this.currentPage < this.questions.length - 1 ? this.currentPage + 1 : this.questions.length); } private finish(cancelled: boolean): void { if (this.completed) return; this.completed = true; this.signal?.removeEventListener("abort", this.abortHandler); this.done({ version: 1, cancelled, questions: this.questions, answers: orderedAnswers(this.questions, this.answers), }); } handleInput(data: string): void { if (this.inputKind) { if (matchesKey(data, Key.escape)) { this.inputKind = null; this.validationMessage = undefined; this.editor.setText(""); this.refresh(); return; } this.editor.handleInput(data); this.refresh(); return; } if (matchesKey(data, Key.escape)) { this.finish(true); return; } if (this.questions.length > 1) { if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { this.movePage(1); return; } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { this.movePage(-1); return; } } if (this.currentPage === this.questions.length) { if (matchesKey(data, Key.enter) && this.allAnswered()) this.finish(false); return; } const question = this.currentQuestion(); if (!question) return; if (question.type === "text") { if (!question.required && matchesKey(data, "s")) { this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true }); this.advanceAfterAnswer(); } else if (matchesKey(data, Key.enter)) { this.enterEditor("text"); } return; } const options = this.currentOptions(); if (matchesKey(data, Key.up)) { this.optionIndex = Math.max(0, this.optionIndex - 1); this.refresh(); return; } if (matchesKey(data, Key.down)) { this.optionIndex = Math.min(options.length - 1, this.optionIndex + 1); this.refresh(); return; } if (matchesKey(data, Key.enter)) this.selectCurrentOption(); } render(width: number): string[] { const renderWidth = Math.max(1, width); if (this.cachedLines && this.cachedWidth === renderWidth) return this.cachedLines; const lines: string[] = []; const question = this.currentQuestion(); const addWrapped = (text: string): void => { lines.push(...wrapTextWithAnsi(text, renderWidth)); }; const addWrappedWithPrefix = (prefix: string, text: string): void => { const prefixWidth = visibleWidth(prefix); if (prefixWidth >= renderWidth) { addWrapped(prefix + text); return; } const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); const continuation = " ".repeat(prefixWidth); wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`)); }; lines.push(this.theme.fg("accent", "─".repeat(renderWidth))); if (this.questions.length > 1) { const tabs = this.questions.map((item, index) => { const answered = this.answers.has(item.id); const text = ` ${answered ? "■" : "□"} ${item.label} `; return index === this.currentPage ? this.theme.bg("selectedBg", this.theme.fg("text", text)) : this.theme.fg(answered ? "success" : "muted", text); }); const reviewText = " ✓ Review "; tabs.push(this.currentPage === this.questions.length ? this.theme.bg("selectedBg", this.theme.fg("text", reviewText)) : this.theme.fg(this.allAnswered() ? "success" : "dim", reviewText)); addWrappedWithPrefix(" ", tabs.join(" ")); lines.push(""); } if (this.currentPage === this.questions.length) { addWrappedWithPrefix(" ", this.theme.fg("accent", this.theme.bold("Review answers"))); lines.push(""); for (const item of this.questions) { const answer = this.answers.get(item.id); const value = !answer ? this.theme.fg("warning", "Unanswered") : answer.skipped ? this.theme.fg("muted", "Skipped") : this.theme.fg("text", answer.label); addWrappedWithPrefix(" ", `${this.theme.fg("muted", `${item.label}: `)}${value}`); } lines.push(""); addWrappedWithPrefix(" ", this.allAnswered() ? this.theme.fg("success", "Press Enter to submit") : this.theme.fg("warning", "Answer or explicitly skip every question before submitting")); } else if (question) { addWrappedWithPrefix(" ", this.theme.fg("text", question.prompt)); lines.push(""); if (question.type === "select") { for (const [index, option] of this.currentOptions().entries()) { const selected = index === this.optionIndex; const prefix = selected ? this.theme.fg("accent", "> ") : " "; addWrappedWithPrefix(prefix, this.theme.fg(selected ? "accent" : option.kind === "skip" ? "muted" : "text", `${index + 1}. ${option.label}`)); if (option.description) addWrappedWithPrefix(" ", this.theme.fg("muted", option.description)); } } else if (!this.inputKind) { const existing = this.answers.get(question.id); if (existing) addWrappedWithPrefix(" ", `${this.theme.fg("muted", "Current answer: ")}${existing.skipped ? "Skipped" : existing.label}`); const action = `${existing ? "Press Enter to edit" : "Press Enter to answer"}${question.required ? "" : " • s to skip"}`; addWrappedWithPrefix(" ", this.theme.fg("accent", action)); } if (this.inputKind) { if (this.inputKind === "custom") lines.push(""); addWrappedWithPrefix(" ", this.theme.fg("muted", "Your answer:")); for (const line of this.editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`); if (this.validationMessage) addWrappedWithPrefix(" ", this.theme.fg("warning", this.validationMessage)); } } lines.push(""); const help = this.inputKind ? "Enter submit • Esc go back" : this.currentPage === this.questions.length ? "Tab/←→ navigate • Enter submit • Esc cancel" : question?.type === "text" ? `${question.required ? "Enter write" : "Enter write • s skip"} • Tab/←→ navigate • Esc cancel` : "↑↓ select • Enter confirm • Tab/←→ navigate • Esc cancel"; addWrappedWithPrefix(" ", this.theme.fg("dim", this.questions.length === 1 ? help.replace(" • Tab/←→ navigate", "") : help)); lines.push(this.theme.fg("accent", "─".repeat(renderWidth))); this.cachedWidth = renderWidth; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; this.editor.invalidate(); } dispose(): void { this.signal?.removeEventListener("abort", this.abortHandler); } }