feat: add interactive ask-user extension

This commit is contained in:
云服务部-叶林立
2026-08-24 20:23:54 +08:00
parent e941e71ed8
commit 939139b5f3
21 changed files with 937 additions and 2 deletions
+363
View File
@@ -0,0 +1,363 @@
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<string, UserAnswer>();
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<UserAnswer, "id" | "question" | "type">): 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);
}
}