repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/ask/ask-overlay.ts
Rawimport type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import {
Editor,
type KeybindingsManager,
type SelectItem,
type TUI,
type TuiMouseEvent,
type TuiMouseEventResult,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import type { AskAnswer, AskQuestion } from "./ask-tool.js";
const OTHER_VALUE = "__other__";
const OTHER_ITEM: SelectItem = {
value: OTHER_VALUE,
label: "Other…",
description: "Type your own answer",
};
export async function askInTui(
title: string,
question: AskQuestion,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AskAnswer | undefined> {
const promise = ctx.ui.custom<AskAnswer | undefined>(
(tui, theme, keybindings, done) =>
new AskQuestionOverlay(tui, theme, keybindings, done, title, question),
);
return await abortable(promise, signal);
}
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
return new Promise<T>((resolve, reject) => {
const onAbort = () => resolve(undefined as T);
if (signal.aborted) {
resolve(undefined as T);
return;
}
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(reason) => {
signal.removeEventListener("abort", onAbort);
reject(reason);
},
);
});
}
type OverlayMode = "list" | "other" | "notes";
class AskQuestionOverlay {
private settled = false;
private mode: OverlayMode = "list";
private readonly list: OptionList;
private readonly otherInput: Editor;
private readonly notesInput: Editor;
private otherText = "";
private otherSelected = false;
private focusedState = true;
private listRow = 0;
private listHeight = 0;
private inputRow: number | undefined;
private inputHeight = 0;
constructor(
private readonly tui: TUI,
private readonly theme: Theme,
private readonly keybindings: KeybindingsManager,
private readonly done: (answer: AskAnswer | undefined) => void,
private readonly title: string,
private readonly question: AskQuestion,
) {
const items: SelectItem[] = [
...question.options.map((option) => ({
value: option.label,
label: option.label,
description: option.description,
})),
OTHER_ITEM,
];
this.list = new OptionList(
items,
question.multiSelect,
theme,
() => this.otherSelected,
() => this.activateCurrent(),
);
const editorTheme = {
borderColor: (text: string) => theme.fg("border", text),
selectList: {
selectedPrefix: (text: string) => theme.fg("accent", text),
selectedText: (text: string) => theme.fg("accent", text),
description: (text: string) => theme.fg("dim", text),
scrollInfo: (text: string) => theme.fg("dim", text),
noMatch: (text: string) => theme.fg("warning", text),
},
};
this.otherInput = new Editor(tui, editorTheme, { paddingX: 1 });
this.otherInput.onSubmit = (value) => this.submitOther(value);
this.notesInput = new Editor(tui, editorTheme, { paddingX: 1 });
this.notesInput.onSubmit = (value) => this.submitFromNotes(value);
}
get focused(): boolean {
return this.focusedState;
}
set focused(value: boolean) {
this.focusedState = value;
this.syncInputFocus();
}
handleInput(data: string): void {
if (this.mode === "other" || this.mode === "notes") {
if (this.keybindings.matches(data, "tui.select.cancel")) {
this.setMode("list");
return;
}
this.activeInput().handleInput(data);
this.tui.requestRender();
return;
}
if (
this.keybindings.matches(data, "tui.select.up") ||
this.keybindings.matches(data, "tui.select.down")
) {
this.list.move(this.keybindings.matches(data, "tui.select.up") ? -1 : 1);
} else if (this.keybindings.matches(data, "tui.select.confirm")) {
this.confirm();
} else if (this.keybindings.matches(data, "tui.input.tab")) {
this.setMode("notes");
} else if (this.keybindings.matches(data, "tui.select.cancel")) {
this.finish(undefined);
} else if (data === " " && this.question.multiSelect) {
this.toggleCurrent();
}
this.tui.requestRender();
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.y >= this.listRow && event.y < this.listRow + this.listHeight) {
return this.list.handleMouse(
localMouseEvent(event, this.listRow, this.listHeight),
);
}
if (
this.inputRow !== undefined &&
event.y >= this.inputRow &&
event.y < this.inputRow + this.inputHeight
) {
const result = this.activeInput().handleMouse(
localMouseEvent(event, this.inputRow, this.inputHeight),
);
return result ? { ...result, focus: true } : undefined;
}
return undefined;
}
render(width: number): string[] {
const lines = [
...wrapStyled(
this.theme.fg("accent", this.theme.bold(this.title)),
width,
),
...wrapStyled(this.theme.fg("toolOutput", this.question.question), width),
"",
];
this.listRow = lines.length;
const listLines = this.list.render(width);
this.listHeight = listLines.length;
lines.push(...listLines);
this.inputRow = undefined;
this.inputHeight = 0;
if (this.mode === "other" || this.mode === "notes") {
lines.push(
"",
...wrapStyled(
this.theme.fg("accent", this.mode === "other" ? "Answer:" : "Notes:"),
width,
),
);
this.inputRow = lines.length;
const inputLines = this.activeInput().render(width);
this.inputHeight = inputLines.length;
lines.push(...inputLines);
}
lines.push(
"",
...wrapStyled(this.theme.fg("dim", this.footerHint()), width),
);
return lines;
}
invalidate(): void {
this.list.invalidate();
this.otherInput.invalidate();
this.notesInput.invalidate();
}
private footerHint(): string {
if (this.mode === "other") return "enter submit · esc back";
if (this.mode === "notes") return "enter submit · esc back to options";
return this.question.multiSelect
? "space/click toggle · enter submit · tab notes · esc cancel"
: "enter/click select · tab notes · esc cancel";
}
private confirm(): void {
if (this.list.current()?.value === OTHER_VALUE) {
this.setMode("other");
return;
}
this.finish(
this.answer(
this.question.multiSelect
? this.multiAnswers()
: this.list.current()
? [this.list.current()?.label ?? ""]
: [],
this.notesDraft(),
),
);
}
private activateCurrent(): void {
if (this.question.multiSelect) this.toggleCurrent();
else this.confirm();
}
private toggleCurrent(): void {
const current = this.list.current();
if (!current) return;
if (current.value === OTHER_VALUE) {
this.setMode("other");
return;
}
this.list.toggle();
}
private submitOther(value: string): void {
const text = value.trim();
if (!text) return;
if (this.question.multiSelect) {
this.otherText = text;
this.otherSelected = true;
this.setMode("list");
return;
}
this.finish(this.answer([text], this.notesDraft()));
}
private submitFromNotes(value: string): void {
const notes = value.trim();
const current = this.list.current();
const answers = this.question.multiSelect
? this.multiAnswers()
: current?.value === OTHER_VALUE
? this.otherText
? [this.otherText]
: []
: current
? [current.label]
: [];
if (answers.length === 0 && !notes) return;
this.finish(this.answer(answers, notes || undefined));
}
private multiAnswers(): string[] {
const labels = this.list.selectedLabels();
return this.otherSelected ? [...labels, this.otherText] : labels;
}
private answer(answers: string[], notes?: string): AskAnswer {
return notes ? { answers, notes } : { answers };
}
private notesDraft(): string | undefined {
return this.notesInput.getText().trim() || undefined;
}
private activeInput(): Editor {
return this.mode === "other" ? this.otherInput : this.notesInput;
}
private setMode(mode: OverlayMode): void {
this.mode = mode;
this.syncInputFocus();
this.tui.requestRender();
}
private syncInputFocus(): void {
this.otherInput.focused = this.focusedState && this.mode === "other";
this.notesInput.focused = this.focusedState && this.mode === "notes";
}
private finish(answer: AskAnswer | undefined): void {
if (this.settled) return;
this.settled = true;
this.done(answer);
}
}
type ItemRows = { start: number; end: number };
class OptionList {
private index = 0;
private readonly toggled = new Set<string>();
private itemRows: ItemRows[] = [];
constructor(
private readonly items: SelectItem[],
private readonly multiSelect: boolean,
private readonly theme: Theme,
private readonly isOtherSelected: () => boolean,
private readonly onActivate: () => void,
) {}
current(): SelectItem | undefined {
return this.items[this.index];
}
move(delta: number): void {
this.index =
(this.index + delta + this.items.length) % Math.max(this.items.length, 1);
}
toggle(): void {
const value = this.current()?.value;
if (value === undefined || value === OTHER_VALUE) return;
if (this.toggled.has(value)) this.toggled.delete(value);
else this.toggled.add(value);
}
selectedLabels(): string[] {
return this.items
.filter(
(item) => this.toggled.has(item.value) && item.value !== OTHER_VALUE,
)
.map((item) => item.label);
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type === "wheel" && event.wheelDelta) {
this.move(event.wheelDelta < 0 ? -1 : 1);
return { handled: true, render: true };
}
if (
event.button !== "left" ||
(event.type !== "press" && event.type !== "click")
)
return undefined;
const itemIndex = this.itemRows.findIndex(
(rows) => event.y >= rows.start && event.y < rows.end,
);
if (itemIndex < 0) return undefined;
this.index = itemIndex;
if (event.type === "click") this.onActivate();
return { handled: true, focus: true, render: true };
}
invalidate(): void {}
render(width: number): string[] {
const lines: string[] = [];
this.itemRows = [];
for (const [index, item] of this.items.entries()) {
const start = lines.length;
const cursor = index === this.index;
const checked =
item.value === OTHER_VALUE
? this.isOtherSelected()
: this.toggled.has(item.value);
const prefix = this.multiSelect
? `${cursor ? "›" : " "} [${checked ? "x" : " "}] `
: `${cursor ? "›" : " "} `;
const labelStyle = cursor ? "accent" : "toolOutput";
lines.push(
...wrapIndented(this.theme.fg(labelStyle, item.label), width, prefix),
);
if (item.description) {
lines.push(
...wrapIndented(this.theme.fg("dim", item.description), width, " "),
);
}
this.itemRows.push({ start, end: lines.length });
}
return lines;
}
}
function wrapStyled(text: string, width: number): string[] {
if (width <= 0) return [""];
return wrapTextWithAnsi(text, width).map((line) =>
truncateToWidth(line, width, ""),
);
}
function wrapIndented(text: string, width: number, prefix: string): string[] {
const prefixWidth = visibleWidth(prefix);
if (width <= prefixWidth) return [truncateToWidth(prefix, width, "")];
const continuation = " ".repeat(prefixWidth);
return wrapTextWithAnsi(text, width - prefixWidth).map((line, index) =>
truncateToWidth(`${index === 0 ? prefix : continuation}${line}`, width, ""),
);
}
function localMouseEvent(
event: TuiMouseEvent,
row: number,
height: number,
): TuiMouseEvent {
return { ...event, y: event.y - row, height };
}