mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: add interactive ask-user extension
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user