The live AskDialog trusted question.question while its render helpers (replaceTabs, renderQuestionTitle, questionTabLabel) assume a string. A question reaching AskDialogComponent without a string question field threw an uncaught TypeError that escaped the TUI render loop and killed the session. The transcript renderer already normalizes the same malformed data via normalizeRenderQuestions; the live path did not. Normalize the questions array at dialog entry (new normalizeDialogQuestions), coercing question/id/label to strings and options to a well-formed array, matching the transcript path. Fixes #7211
1018 lines
35 KiB
TypeScript
1018 lines
35 KiB
TypeScript
import {
|
|
type Component,
|
|
Ellipsis,
|
|
Markdown,
|
|
type MarkdownTheme,
|
|
matchesKey,
|
|
renderInlineMarkdown,
|
|
replaceTabs,
|
|
ScrollView,
|
|
type Tab,
|
|
TabBar,
|
|
Text,
|
|
type TUI,
|
|
truncateToWidth,
|
|
wrapTextWithAnsi,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import type {
|
|
ExtensionAskDialogOption,
|
|
ExtensionAskDialogQuestion,
|
|
ExtensionAskDialogResultItem,
|
|
ExtensionAskDialogSubmitResult,
|
|
} from "../../extensibility/extensions";
|
|
import { getTabBarTheme } from "../shared";
|
|
import { getMarkdownTheme, highlightCode, theme } from "../theme/theme";
|
|
import {
|
|
matchesSelectCancel,
|
|
matchesSelectDown,
|
|
matchesSelectPageDown,
|
|
matchesSelectPageUp,
|
|
matchesSelectUp,
|
|
} from "../utils/keybinding-matchers";
|
|
import { CountdownTimer } from "./countdown-timer";
|
|
import { editorKey } from "./keybinding-hints";
|
|
import { bottomBorder, divider, row, topBorder } from "./overlay-box";
|
|
import { handleTabSwitchKey } from "./selector-helpers";
|
|
|
|
const OTHER_OPTION = "Other (type your own)";
|
|
const SUBMIT_OPTION = "Submit";
|
|
|
|
/** Fraction of the terminal the dialog may occupy. The box height is fixed
|
|
* at spawn from the tallest tab's content (re-measured only on viewport
|
|
* resize) and clamped to this ratio; it rises from the bottom as a stable
|
|
* panel that never resizes on tab switches or cursor moves. */
|
|
const DIALOG_HEIGHT_RATIO = 0.7;
|
|
const MIN_DIALOG_ROWS = 12;
|
|
const MIN_BODY_ROWS = 5;
|
|
const MAX_HEADER_CHIP_WIDTH = 16;
|
|
/** Maximum number of title lines shown in the prompt editor overlay, so a
|
|
* long or multi-line question cannot push the input row off-screen. Mirrors
|
|
* the bounded-title pattern from the legacy ask path without its option-window
|
|
* coupling. */
|
|
const MAX_PROMPT_TITLE_ROWS = 3;
|
|
/** Border (2) + padX (2) columns consumed by the HookEditor chrome. */
|
|
const PROMPT_TITLE_CHROME_COLUMNS = 4;
|
|
/** Maximum number of wrapped lines for an in-body question header, so a long
|
|
* or multi-line question cannot push the option list off-screen. Mirrors the
|
|
* row-cap pattern used by boundPromptTitle for the prompt editor overlay. */
|
|
const MAX_HEADER_ROWS = 4;
|
|
|
|
function promptTitleContentWidth(): number {
|
|
const cols = process.stdout.columns ?? 80;
|
|
return Math.max(1, cols - PROMPT_TITLE_CHROME_COLUMNS);
|
|
}
|
|
|
|
/** Bound a prompt editor title to a fixed row/width budget so long or
|
|
* multi-line questions stay usable inside the small prompt overlay. */
|
|
export function boundPromptTitle(prefix: string, question: string): string {
|
|
const width = promptTitleContentWidth();
|
|
const flat = normalizedInlineInput(`${prefix}${question}`);
|
|
const wrapped = wrapTextWithAnsi(flat, width);
|
|
if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
|
|
const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
|
|
const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, Ellipsis.Unicode);
|
|
return [...kept, last].join("\n");
|
|
}
|
|
|
|
interface AskDialogCallbacks {
|
|
onSubmit(result: ExtensionAskDialogSubmitResult): void;
|
|
onCancel(): void;
|
|
onPrompt(title: string, prefill?: string): Promise<string | undefined>;
|
|
}
|
|
|
|
interface AskDialogInputGuard {
|
|
isBlocked(): boolean;
|
|
handleInput(keyData: string): void;
|
|
hint: string;
|
|
/** Mirror the guard's blocked state onto the proxied draft surface each
|
|
* render, so a draft that owns input shows a visible insertion cursor even
|
|
* though this dialog holds TUI focus. */
|
|
syncPresentation?(): void;
|
|
}
|
|
|
|
interface AskDialogOptions {
|
|
timeout?: number;
|
|
onTimeout?: () => void;
|
|
tui?: TUI;
|
|
inputGuard?: AskDialogInputGuard;
|
|
}
|
|
|
|
interface QuestionState {
|
|
selectedOptions: Set<string>;
|
|
customInput: string | undefined;
|
|
note: string | undefined;
|
|
noteRowKey: string | undefined;
|
|
cursorIndex: number;
|
|
scrollOffset: number;
|
|
manualScroll: boolean;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
type QuestionRowKind = "option" | "other";
|
|
|
|
interface QuestionRow {
|
|
kind: QuestionRowKind;
|
|
key: string;
|
|
label: string;
|
|
optionIndex: number | undefined;
|
|
}
|
|
|
|
interface RenderedList {
|
|
lines: string[];
|
|
scrollOffset: number;
|
|
indicator: string;
|
|
}
|
|
|
|
interface PreviewSegment {
|
|
kind: "markdown" | "code";
|
|
text: string;
|
|
language: string | undefined;
|
|
}
|
|
|
|
type PreviewRenderCache = Map<string, Map<number, readonly string[]>>;
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(value, max));
|
|
}
|
|
|
|
function stripRecommendedSuffix(label: string): string {
|
|
const suffix = " (Recommended)";
|
|
return label.endsWith(suffix) ? label.slice(0, -suffix.length) : label;
|
|
}
|
|
|
|
function questionTabLabel(question: ExtensionAskDialogQuestion, index: number): string {
|
|
const base = question.header?.trim() || question.id || `Q${index + 1}`;
|
|
return truncateToWidth(replaceTabs(base), MAX_HEADER_CHIP_WIDTH, Ellipsis.Unicode);
|
|
}
|
|
|
|
function renderQuestionTitle(question: ExtensionAskDialogQuestion, width: number): string[] {
|
|
const mdTheme = getMarkdownTheme();
|
|
const questionText = renderInlineMarkdown(replaceTabs(question.question), mdTheme, t => theme.fg("text", t));
|
|
const wrapped = wrapTextWithAnsi(questionText, Math.max(1, width));
|
|
if (wrapped.length <= MAX_HEADER_ROWS) return wrapped;
|
|
return [
|
|
...wrapped.slice(0, MAX_HEADER_ROWS - 1),
|
|
truncateToWidth(wrapped.slice(MAX_HEADER_ROWS - 1).join(" "), Math.max(1, width), Ellipsis.Unicode),
|
|
];
|
|
}
|
|
|
|
function splitPreviewSegments(preview: string): PreviewSegment[] {
|
|
const segments: PreviewSegment[] = [];
|
|
const markdownBuffer: string[] = [];
|
|
let fenceChar: string | undefined;
|
|
let fenceLength = 0;
|
|
let fenceLanguage: string | undefined;
|
|
let codeBuffer: string[] = [];
|
|
|
|
const flushMarkdown = (): void => {
|
|
if (markdownBuffer.length === 0) return;
|
|
segments.push({ kind: "markdown", text: markdownBuffer.join("\n"), language: undefined });
|
|
markdownBuffer.length = 0;
|
|
};
|
|
const flushCode = (): void => {
|
|
segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
|
|
codeBuffer = [];
|
|
fenceChar = undefined;
|
|
fenceLength = 0;
|
|
fenceLanguage = undefined;
|
|
};
|
|
|
|
for (const line of replaceTabs(preview).split("\n")) {
|
|
const fenceMatch = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line);
|
|
if (fenceChar !== undefined) {
|
|
if (fenceMatch) {
|
|
const marker = fenceMatch[2] ?? "";
|
|
const info = fenceMatch[3]?.trim() ?? "";
|
|
if (marker.startsWith(fenceChar) && marker.length >= fenceLength && info === "") {
|
|
flushCode();
|
|
continue;
|
|
}
|
|
}
|
|
codeBuffer.push(line);
|
|
continue;
|
|
}
|
|
if (fenceMatch) {
|
|
flushMarkdown();
|
|
const marker = fenceMatch[2] ?? "";
|
|
fenceChar = marker[0];
|
|
fenceLength = marker.length;
|
|
fenceLanguage = fenceMatch[3]?.trim().split(/\s+/, 1)[0] || undefined;
|
|
codeBuffer = [];
|
|
continue;
|
|
}
|
|
markdownBuffer.push(line);
|
|
}
|
|
|
|
if (fenceChar !== undefined) {
|
|
segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
|
|
} else {
|
|
flushMarkdown();
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function renderPreviewContent(preview: string, width: number): string[] {
|
|
const out: string[] = [];
|
|
const mdTheme = getMarkdownTheme();
|
|
const accentStyle = { color: (text: string) => theme.fg("muted", text) };
|
|
for (const segment of splitPreviewSegments(preview)) {
|
|
if (segment.kind === "code") {
|
|
const highlighted = highlightCode(segment.text, segment.language);
|
|
const text = new Text(highlighted.join("\n"), 0, 0);
|
|
out.push(...text.render(Math.max(1, width)));
|
|
continue;
|
|
}
|
|
const markdown = new Markdown(segment.text, 0, 0, mdTheme, accentStyle);
|
|
out.push(...markdown.render(Math.max(1, width)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function renderCachedPreview(cache: PreviewRenderCache, preview: string, width: number): readonly string[] {
|
|
let byWidth = cache.get(preview);
|
|
if (!byWidth) {
|
|
byWidth = new Map();
|
|
cache.set(preview, byWidth);
|
|
}
|
|
let rendered = byWidth.get(width);
|
|
if (!rendered) {
|
|
rendered = renderPreviewContent(preview, width).map(line => ` ${theme.fg("border", "│")} ${line}`);
|
|
byWidth.set(width, rendered);
|
|
}
|
|
return rendered;
|
|
}
|
|
|
|
function pageKeysLabel(): string {
|
|
const pageUp = editorKey("tui.select.pageUp");
|
|
const pageDown = editorKey("tui.select.pageDown");
|
|
return `${pageUp === "pageup" ? "PgUp" : pageUp}/${pageDown === "pagedown" ? "PgDn" : pageDown}`;
|
|
}
|
|
|
|
function cancelKeyLabel(): string {
|
|
const [key = ""] = editorKey("tui.select.cancel").split("/");
|
|
return key === "escape" ? "Esc" : key;
|
|
}
|
|
|
|
function normalizedInlineInput(input: string): string {
|
|
return replaceTabs(input).replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function renderAnswerSummary(question: ExtensionAskDialogQuestion, state: QuestionState): string {
|
|
const selected = question.options.map(option => option.label).filter(label => state.selectedOptions.has(label));
|
|
if (question.multi) {
|
|
const answers = [...selected];
|
|
if (state.customInput !== undefined) answers.push(`Other: “${normalizedInlineInput(state.customInput)}”`);
|
|
return answers.length > 0 ? answers.join(", ") : theme.fg("warning", "unanswered");
|
|
}
|
|
if (state.customInput !== undefined) return `“${normalizedInlineInput(state.customInput)}”`;
|
|
if (selected.length === 0) return theme.fg("warning", "unanswered");
|
|
return selected[0] ?? theme.fg("warning", "unanswered");
|
|
}
|
|
|
|
function clearNote(state: QuestionState): void {
|
|
state.note = undefined;
|
|
state.noteRowKey = undefined;
|
|
}
|
|
|
|
function clearNoteIfRow(state: QuestionState, rowKey: string): void {
|
|
if (state.noteRowKey === rowKey) clearNote(state);
|
|
}
|
|
|
|
function clearNoteUnlessRow(state: QuestionState, rowKey: string): void {
|
|
if (state.noteRowKey !== undefined && state.noteRowKey !== rowKey) clearNote(state);
|
|
}
|
|
|
|
function noteForSubmittedAnswer(question: ExtensionAskDialogQuestion, state: QuestionState): string | undefined {
|
|
if (state.note === undefined || state.noteRowKey === undefined) return undefined;
|
|
if (state.noteRowKey === "other") return state.customInput !== undefined ? state.note : undefined;
|
|
const match = /^option:(\d+)$/.exec(state.noteRowKey);
|
|
const optionIndex = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
|
|
const option = Number.isInteger(optionIndex) ? question.options[optionIndex] : undefined;
|
|
return option && state.selectedOptions.has(option.label) ? state.note : undefined;
|
|
}
|
|
|
|
function optionMarker(question: ExtensionAskDialogQuestion, checked: boolean): string {
|
|
if (question.multi) return checked ? theme.checkbox.checked : theme.checkbox.unchecked;
|
|
return checked ? theme.radio.selected : theme.radio.unselected;
|
|
}
|
|
|
|
function renderRowLabel(
|
|
rowItem: QuestionRow,
|
|
question: ExtensionAskDialogQuestion,
|
|
state: QuestionState,
|
|
selected: boolean,
|
|
mdTheme: MarkdownTheme,
|
|
previewCache: PreviewRenderCache,
|
|
width: number,
|
|
): string[] {
|
|
const isOption = rowItem.kind === "option";
|
|
const isOther = rowItem.kind === "other";
|
|
const checked = isOption
|
|
? state.selectedOptions.has(stripRecommendedSuffix(rowItem.label))
|
|
: isOther && state.customInput !== undefined;
|
|
const color = selected ? "accent" : checked ? "toolOutput" : "text";
|
|
const marker = `${theme.fg(checked ? "success" : "dim", optionMarker(question, checked))} `;
|
|
const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
|
|
const label = renderInlineMarkdown(rowItem.label, mdTheme, t => theme.fg(color, t));
|
|
const noteMarker = state.note && state.noteRowKey === rowItem.key ? theme.fg("success", " ✎ note") : "";
|
|
const firstLine = `${cursor}${marker}${label}${noteMarker}`;
|
|
const lines = [truncateToWidth(firstLine, width, Ellipsis.Unicode)];
|
|
if (rowItem.kind === "option") {
|
|
const option = question.options[rowItem.optionIndex ?? -1];
|
|
if (option?.description?.trim()) {
|
|
const description = renderInlineMarkdown(option.description.trim(), mdTheme, t => theme.fg("muted", t));
|
|
const wrapped = wrapTextWithAnsi(description, Math.max(1, width - 6));
|
|
for (const line of wrapped.slice(0, 2)) {
|
|
lines.push(` ${truncateToWidth(line, Math.max(1, width - 6), Ellipsis.Unicode)}`);
|
|
}
|
|
}
|
|
if (option?.preview?.trim()) {
|
|
const previewWidth = Math.max(1, width - 8);
|
|
lines.push(...renderCachedPreview(previewCache, option.preview, previewWidth));
|
|
}
|
|
}
|
|
if (isOther && state.customInput !== undefined) {
|
|
const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
|
|
lines.push(theme.fg("muted", ` ${truncateToWidth(preview, Math.max(1, width - 6), Ellipsis.Unicode)}`));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Coerce untrusted dialog questions into a render-safe shape. The live ask
|
|
* dialog is reached from the public `askDialog` extension surface and from
|
|
* streamed tool args, where a question entry can arrive with a missing or
|
|
* non-string `question` field. The render helpers (`replaceTabs`,
|
|
* `renderQuestionTitle`, `questionTabLabel`) assume strings, so a malformed
|
|
* entry throws and takes down the whole TUI render loop. Mirrors
|
|
* `normalizeRenderQuestions` on the transcript path.
|
|
*/
|
|
function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogQuestion[] {
|
|
if (!Array.isArray(questions)) return [];
|
|
const out: ExtensionAskDialogQuestion[] = [];
|
|
for (const entry of questions) {
|
|
if (!entry || typeof entry !== "object") continue;
|
|
const q = entry as Partial<ExtensionAskDialogQuestion>;
|
|
const options: ExtensionAskDialogOption[] = [];
|
|
if (Array.isArray(q.options)) {
|
|
for (const opt of q.options) {
|
|
if (!opt || typeof opt !== "object") continue;
|
|
const o = opt as Partial<ExtensionAskDialogOption>;
|
|
options.push({
|
|
label: typeof o.label === "string" ? o.label : "",
|
|
...(typeof o.description === "string" ? { description: o.description } : {}),
|
|
...(typeof o.preview === "string" ? { preview: o.preview } : {}),
|
|
});
|
|
}
|
|
}
|
|
out.push({
|
|
id: typeof q.id === "string" ? q.id : "?",
|
|
question: typeof q.question === "string" ? q.question : "",
|
|
...(typeof q.header === "string" ? { header: q.header } : {}),
|
|
options,
|
|
...(typeof q.multi === "boolean" ? { multi: q.multi } : {}),
|
|
...(Number.isInteger(q.recommended) ? { recommended: q.recommended } : {}),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export class AskDialogComponent implements Component {
|
|
#states: QuestionState[];
|
|
#activeTabIndex = 0;
|
|
#submitScrollOffset = 0;
|
|
#bodyRows = MIN_BODY_ROWS;
|
|
#questionCanPage = false;
|
|
#remainingSeconds: number | undefined;
|
|
#countdown: CountdownTimer | undefined;
|
|
#promptActive = false;
|
|
#timeoutExpired = false;
|
|
#closed = false;
|
|
#tabBar: TabBar | undefined;
|
|
#stableHeight: { key: string; total: number } | undefined;
|
|
#previewCache: PreviewRenderCache = new Map();
|
|
#overflowLayouts = new WeakMap<ExtensionAskDialogQuestion, Set<string>>();
|
|
readonly #questions: ExtensionAskDialogQuestion[];
|
|
|
|
constructor(
|
|
questions: ExtensionAskDialogQuestion[],
|
|
private readonly callbacks: AskDialogCallbacks,
|
|
private readonly options: AskDialogOptions = {},
|
|
) {
|
|
this.#questions = normalizeDialogQuestions(questions);
|
|
this.#states = this.#questions.map(question => {
|
|
const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
|
|
const maxIndex = Math.max(0, question.options.length - 1);
|
|
return {
|
|
selectedOptions: new Set<string>(),
|
|
customInput: undefined,
|
|
note: undefined,
|
|
noteRowKey: undefined,
|
|
cursorIndex: clamp(recommended ?? 0, 0, maxIndex),
|
|
scrollOffset: 0,
|
|
manualScroll: false,
|
|
timedOut: false,
|
|
};
|
|
});
|
|
if (options.timeout && options.timeout > 0) {
|
|
this.#countdown = new CountdownTimer(
|
|
options.timeout,
|
|
options.tui,
|
|
seconds => {
|
|
this.#remainingSeconds = seconds;
|
|
},
|
|
() => this.#handleTimeout(),
|
|
);
|
|
}
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.#stableHeight = undefined;
|
|
this.#previewCache.clear();
|
|
this.#overflowLayouts = new WeakMap();
|
|
this.#tabBar?.invalidate();
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#closed = true;
|
|
this.#countdown?.dispose();
|
|
}
|
|
|
|
handleInput(keyData: string): void {
|
|
if (this.#closed || this.#promptActive) return;
|
|
// Reset the inactivity countdown on any key that reaches past the
|
|
// closed/prompt guards, matching HookSelector/HookInput semantics.
|
|
this.#countdown?.reset();
|
|
if (matchesSelectCancel(keyData)) {
|
|
this.#finishCancel();
|
|
return;
|
|
}
|
|
const inputGuard = this.options.inputGuard;
|
|
if (inputGuard?.isBlocked()) {
|
|
inputGuard.handleInput(keyData);
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (this.#hasSubmitTab() && handleTabSwitchKey(keyData, direction => this.#switchTab(direction))) {
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (this.#isSubmitTab()) {
|
|
this.#handleSubmitTabInput(keyData);
|
|
return;
|
|
}
|
|
this.#handleQuestionInput(keyData);
|
|
}
|
|
|
|
render(width: number): readonly string[] {
|
|
// Keep the proxied draft's cursor visible while it owns input (the editor
|
|
// renders as the next sibling in the same container, so this lands in the
|
|
// same frame).
|
|
this.options.inputGuard?.syncPresentation?.();
|
|
const innerWidth = Math.max(1, width - 4);
|
|
// Fixed panel height: measured from the tallest tab at spawn and
|
|
// re-measured only when the viewport changes. Tab switches, cursor
|
|
// moves, and later answers never resize the box; content that
|
|
// outgrows it scrolls.
|
|
const totalRows = this.#dialogHeight(innerWidth, process.stdout.rows || 40);
|
|
const headerLines = this.#renderHeader(innerWidth);
|
|
// topBorder(1) + header(N) + divider(1) + divider(1) + footer(1) +
|
|
// bottomBorder(1) = N + 5 fixed rows outside the body. Without the
|
|
// bottomBorder term the dialog overflowed the viewport by one row
|
|
// (PRRT_kwDOQxs0bc6OFbDY).
|
|
const fixedRows = 1 + headerLines.length + 1 + 1 + 1 + 1;
|
|
const bodyRows = Math.max(MIN_BODY_ROWS, totalRows - fixedRows);
|
|
this.#bodyRows = bodyRows;
|
|
const bodyLines = this.#isSubmitTab()
|
|
? this.#renderSubmitBody(innerWidth, bodyRows)
|
|
: this.#renderQuestionBody(innerWidth, bodyRows);
|
|
const footer = this.#footerHintText(bodyLines.indicator);
|
|
return [
|
|
topBorder(width, this.#titleText()),
|
|
...headerLines.map(line => row(line, width)),
|
|
divider(width),
|
|
...bodyLines.lines.map(line => row(line, width)),
|
|
divider(width),
|
|
row(theme.fg("dim", footer), width),
|
|
bottomBorder(width),
|
|
];
|
|
}
|
|
|
|
#dialogHeight(width: number, termRows: number): number {
|
|
const key = `${width}:${termRows}`;
|
|
if (this.#stableHeight?.key === key) return this.#stableHeight.total;
|
|
const total = this.#measureHeight(width, termRows);
|
|
this.#stableHeight = { key, total };
|
|
return total;
|
|
}
|
|
|
|
/** Measure the tallest tab's natural content height, clamped to
|
|
* DIALOG_HEIGHT_RATIO of the terminal. Derived from questions and
|
|
* viewport only — never from cursor, tab, or answer state — so the box
|
|
* size is stable for the dialog's lifetime at a given terminal size. */
|
|
#measureHeight(width: number, termRows: number): number {
|
|
const maxHeight = Math.max(MIN_DIALOG_ROWS, Math.floor(termRows * DIALOG_HEIGHT_RATIO));
|
|
const chrome = 5; // topBorder + divider + divider + footer + bottomBorder
|
|
const tabBarRows = this.#hasSubmitTab() ? 1 : 0;
|
|
const mdTheme = getMarkdownTheme();
|
|
let needed = MIN_DIALOG_ROWS;
|
|
for (let index = 0; index < this.#questions.length; index++) {
|
|
const question = this.#questions[index];
|
|
const state = this.#states[index];
|
|
if (!question || !state) continue;
|
|
const headerRows = tabBarRows + renderQuestionTitle(question, width).length;
|
|
const rowItems = this.#questionRows(question);
|
|
const listRows = (listWidth: number): number => {
|
|
let total = 0;
|
|
for (const rowItem of rowItems) {
|
|
total += renderRowLabel(rowItem, question, state, false, mdTheme, this.#previewCache, listWidth).length;
|
|
}
|
|
return total;
|
|
};
|
|
const body = listRows(width);
|
|
needed = Math.max(needed, chrome + headerRows + Math.max(MIN_BODY_ROWS, body));
|
|
}
|
|
if (this.#hasSubmitTab()) {
|
|
// Warning line + blank, one summary line per question, blank, and
|
|
// the Submit row; note lines added later scroll within the body.
|
|
const body = 2 + this.#questions.length + 2;
|
|
needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
|
|
}
|
|
return Math.min(needed, maxHeight);
|
|
}
|
|
|
|
#titleText(): string {
|
|
return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`;
|
|
}
|
|
|
|
#hasSubmitTab(): boolean {
|
|
// Multi questions confirm on the Submit tab (Enter toggles, never
|
|
// submits), so any multi question forces the tab even when there is
|
|
// only one question.
|
|
return this.#questions.length > 1 || this.#questions.some(question => question.multi);
|
|
}
|
|
|
|
#submitTabIndex(): number {
|
|
return this.#questions.length;
|
|
}
|
|
|
|
#isSubmitTab(): boolean {
|
|
return this.#hasSubmitTab() && this.#activeTabIndex === this.#submitTabIndex();
|
|
}
|
|
|
|
#currentQuestionIndex(): number {
|
|
return clamp(this.#activeTabIndex, 0, Math.max(0, this.#questions.length - 1));
|
|
}
|
|
|
|
#requestRender(): void {
|
|
this.options.tui?.requestRender();
|
|
}
|
|
|
|
#renderHeader(width: number): string[] {
|
|
const lines: string[] = [];
|
|
if (this.#hasSubmitTab()) {
|
|
const tabs: Tab[] = [
|
|
...this.#questions.map((question, index) => ({
|
|
id: String(index),
|
|
label: questionTabLabel(question, index),
|
|
})),
|
|
{ id: "submit", label: "Submit" },
|
|
];
|
|
this.#tabBar = new TabBar("", tabs, getTabBarTheme(), this.#activeTabIndex);
|
|
this.#tabBar.showHint = false;
|
|
lines.push(...this.#tabBar.render(width));
|
|
}
|
|
if (this.#isSubmitTab()) {
|
|
lines.push(theme.bold(theme.fg("accent", "Review answers")));
|
|
return lines;
|
|
}
|
|
const questionIndex = this.#currentQuestionIndex();
|
|
const question = this.#questions[questionIndex];
|
|
if (!question) return lines;
|
|
lines.push(...renderQuestionTitle(question, width));
|
|
return lines;
|
|
}
|
|
|
|
#footerHintText(indicator: string): string {
|
|
const cancel = `${cancelKeyLabel()} cancel`;
|
|
const inputGuard = this.options.inputGuard;
|
|
if (inputGuard?.isBlocked()) return `${inputGuard.hint} · ${cancel}`;
|
|
if (this.#isSubmitTab()) {
|
|
const scroll = indicator ? ` ${indicator} scroll ·` : "";
|
|
return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
|
|
}
|
|
const question = this.#questions[this.#currentQuestionIndex()];
|
|
const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
|
|
const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
|
|
if (this.#questionCanPage && indicator) {
|
|
return `${action} · ↑/↓${tabs} · ${cancel} · ${pageKeysLabel()} ${indicator}`;
|
|
}
|
|
const scroll = indicator ? ` ${indicator} scroll ·` : "";
|
|
return `${action} · ↑/↓ move${tabs} ·${scroll} ${cancel}`;
|
|
}
|
|
|
|
#questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] {
|
|
const rows: QuestionRow[] = question.options.map((option, index) => ({
|
|
kind: "option",
|
|
key: `option:${index}`,
|
|
label: this.#optionLabel(question, option.label, index),
|
|
optionIndex: index,
|
|
}));
|
|
rows.push({ kind: "other", key: "other", label: OTHER_OPTION, optionIndex: undefined });
|
|
return rows;
|
|
}
|
|
|
|
#optionLabel(question: ExtensionAskDialogQuestion, label: string, index: number): string {
|
|
return question.recommended === index ? `${label} (Recommended)` : label;
|
|
}
|
|
|
|
#activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
|
|
const question = this.#questions[this.#currentQuestionIndex()];
|
|
const state = this.#states[this.#currentQuestionIndex()];
|
|
if (!question || !state) return undefined;
|
|
return { question, state };
|
|
}
|
|
|
|
#handleQuestionInput(keyData: string): void {
|
|
const active = this.#activeQuestionState();
|
|
if (!active) return;
|
|
const { question, state } = active;
|
|
const rows = this.#questionRows(question);
|
|
if (matchesSelectPageUp(keyData)) {
|
|
state.scrollOffset = Math.max(0, state.scrollOffset - Math.max(1, this.#bodyRows - 1));
|
|
state.manualScroll = true;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesSelectPageDown(keyData)) {
|
|
state.scrollOffset += Math.max(1, this.#bodyRows - 1);
|
|
state.manualScroll = true;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesSelectUp(keyData)) {
|
|
state.cursorIndex = clamp(state.cursorIndex - 1, 0, Math.max(0, rows.length - 1));
|
|
state.manualScroll = false;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesSelectDown(keyData)) {
|
|
state.cursorIndex = clamp(state.cursorIndex + 1, 0, Math.max(0, rows.length - 1));
|
|
state.manualScroll = false;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
const rowItem = rows[state.cursorIndex];
|
|
if (!rowItem) return;
|
|
if (keyData === "n" || keyData === "N") {
|
|
if (rowItem.kind === "option" || rowItem.kind === "other") {
|
|
void this.#promptForNote(question, state, rowItem);
|
|
}
|
|
return;
|
|
}
|
|
const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
|
|
const isSpace = matchesKey(keyData, "space") || keyData === " ";
|
|
if (!isEnter && !(question.multi && isSpace)) return;
|
|
if (rowItem.kind === "other") {
|
|
void this.#promptForCustomInput(question, state, rowItem);
|
|
return;
|
|
}
|
|
const option = question.options[rowItem.optionIndex ?? -1];
|
|
if (!option) return;
|
|
if (question.multi) {
|
|
// Multi is toggle-only: Enter and Space both toggle, and the
|
|
// answer is confirmed from the Submit tab.
|
|
if (state.selectedOptions.has(option.label)) {
|
|
state.selectedOptions.delete(option.label);
|
|
clearNoteIfRow(state, rowItem.key);
|
|
} else {
|
|
state.selectedOptions.add(option.label);
|
|
}
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
state.selectedOptions = new Set([option.label]);
|
|
state.customInput = undefined;
|
|
clearNoteUnlessRow(state, rowItem.key);
|
|
this.#advanceAfterQuestion();
|
|
}
|
|
|
|
#handleSubmitTabInput(keyData: string): void {
|
|
if (matchesSelectUp(keyData)) {
|
|
this.#submitScrollOffset = Math.max(0, this.#submitScrollOffset - 1);
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesSelectDown(keyData)) {
|
|
// Clamped against the rendered line count in #renderSubmitBody.
|
|
this.#submitScrollOffset += 1;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
|
|
if (isEnter) this.#finishSubmit();
|
|
}
|
|
|
|
#switchTab(direction: 1 | -1): void {
|
|
const tabCount = this.#questions.length + 1;
|
|
this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
|
|
this.#submitScrollOffset = 0;
|
|
}
|
|
|
|
#advanceAfterQuestion(): void {
|
|
const current = this.#currentQuestionIndex();
|
|
if (this.#questions.length === 1) {
|
|
this.#finishSubmit();
|
|
return;
|
|
}
|
|
this.#activeTabIndex = current + 1 < this.#questions.length ? current + 1 : this.#submitTabIndex();
|
|
this.#submitScrollOffset = 0;
|
|
this.#requestRender();
|
|
}
|
|
|
|
async #promptForCustomInput(
|
|
question: ExtensionAskDialogQuestion,
|
|
state: QuestionState,
|
|
rowItem: QuestionRow,
|
|
): Promise<void> {
|
|
this.#promptActive = true;
|
|
try {
|
|
const input = await this.callbacks.onPrompt(
|
|
boundPromptTitle("Custom answer: ", question.question),
|
|
state.customInput,
|
|
);
|
|
if (input === undefined || this.#closed) return;
|
|
if (input.trim() === "") {
|
|
// Submitting an empty value unselects the custom answer.
|
|
state.customInput = undefined;
|
|
clearNoteIfRow(state, rowItem.key);
|
|
return;
|
|
}
|
|
state.customInput = input;
|
|
if (!question.multi) {
|
|
state.selectedOptions.clear();
|
|
clearNoteUnlessRow(state, rowItem.key);
|
|
this.#advanceAfterQuestion();
|
|
}
|
|
} finally {
|
|
this.#promptActive = false;
|
|
this.#runDeferredTimeout();
|
|
this.#requestRender();
|
|
}
|
|
}
|
|
|
|
async #promptForNote(
|
|
question: ExtensionAskDialogQuestion,
|
|
state: QuestionState,
|
|
rowItem: QuestionRow,
|
|
): Promise<void> {
|
|
this.#promptActive = true;
|
|
try {
|
|
const input = await this.callbacks.onPrompt(
|
|
boundPromptTitle(`Note for ${rowItem.label}: `, question.question),
|
|
state.noteRowKey === rowItem.key ? state.note : undefined,
|
|
);
|
|
if (input === undefined || this.#closed) return;
|
|
state.note = input;
|
|
state.noteRowKey = rowItem.key;
|
|
} finally {
|
|
this.#promptActive = false;
|
|
this.#runDeferredTimeout();
|
|
this.#requestRender();
|
|
}
|
|
}
|
|
|
|
#renderQuestionBody(width: number, maxRows: number): RenderedList {
|
|
const active = this.#activeQuestionState();
|
|
if (!active) return { lines: [], scrollOffset: 0, indicator: "" };
|
|
const { question, state } = active;
|
|
const rowItems = this.#questionRows(question);
|
|
state.cursorIndex = clamp(state.cursorIndex, 0, Math.max(0, rowItems.length - 1));
|
|
return this.#renderQuestionList(question, state, rowItems, width, maxRows);
|
|
}
|
|
|
|
#renderQuestionList(
|
|
question: ExtensionAskDialogQuestion,
|
|
state: QuestionState,
|
|
rowItems: QuestionRow[],
|
|
width: number,
|
|
rows: number,
|
|
): RenderedList {
|
|
const mdTheme = getMarkdownTheme();
|
|
const renderRows = (contentWidth: number): { allLines: string[]; lineStartByRow: number[] } => {
|
|
const allLines: string[] = [];
|
|
const lineStartByRow: number[] = [];
|
|
for (let index = 0; index < rowItems.length; index++) {
|
|
lineStartByRow.push(allLines.length);
|
|
const rowItem = rowItems[index];
|
|
if (!rowItem) continue;
|
|
allLines.push(
|
|
...renderRowLabel(
|
|
rowItem,
|
|
question,
|
|
state,
|
|
index === state.cursorIndex,
|
|
mdTheme,
|
|
this.#previewCache,
|
|
contentWidth,
|
|
),
|
|
);
|
|
}
|
|
return { allLines, lineStartByRow };
|
|
};
|
|
const layoutKey = `${width}:${rows}:${state.customInput === undefined ? 0 : 1}`;
|
|
let overflowLayouts = this.#overflowLayouts.get(question);
|
|
const knownOverflow = overflowLayouts?.has(layoutKey) ?? false;
|
|
let renderedRows = renderRows(knownOverflow && width > 1 ? width - 1 : width);
|
|
if (!knownOverflow && width > 1 && renderedRows.allLines.length > rows) {
|
|
if (!overflowLayouts) {
|
|
overflowLayouts = new Set();
|
|
this.#overflowLayouts.set(question, overflowLayouts);
|
|
}
|
|
overflowLayouts.add(layoutKey);
|
|
renderedRows = renderRows(width - 1);
|
|
}
|
|
const { allLines, lineStartByRow } = renderedRows;
|
|
const cursorStart = lineStartByRow[state.cursorIndex] ?? 0;
|
|
const cursorEnd = lineStartByRow[state.cursorIndex + 1] ?? allLines.length;
|
|
this.#questionCanPage = cursorEnd - cursorStart > rows;
|
|
state.scrollOffset = this.#scrollOffsetForCursor(
|
|
state.scrollOffset,
|
|
cursorStart,
|
|
cursorEnd,
|
|
rows,
|
|
allLines.length,
|
|
state.manualScroll,
|
|
);
|
|
const scrollView = new ScrollView(allLines, {
|
|
height: rows,
|
|
scrollbar: "auto",
|
|
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
});
|
|
scrollView.setScrollOffset(state.scrollOffset);
|
|
const lines = [...scrollView.render(width)];
|
|
while (lines.length < rows) lines.push("");
|
|
return {
|
|
lines: lines.slice(0, rows),
|
|
scrollOffset: state.scrollOffset,
|
|
indicator: this.#clipIndicator(state.scrollOffset, rows, allLines.length),
|
|
};
|
|
}
|
|
|
|
#renderSubmitBody(width: number, rows: number): RenderedList {
|
|
const allLines: string[] = [];
|
|
const unanswered = this.#unansweredCount();
|
|
if (unanswered > 0) {
|
|
allLines.push(
|
|
theme.fg(
|
|
"warning",
|
|
`${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; Enter still submits.`,
|
|
),
|
|
);
|
|
allLines.push("");
|
|
}
|
|
for (let index = 0; index < this.#questions.length; index++) {
|
|
const question = this.#questions[index];
|
|
const state = this.#states[index];
|
|
if (!question || !state) continue;
|
|
const label = questionTabLabel(question, index);
|
|
const answer = renderAnswerSummary(question, state);
|
|
allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`);
|
|
const submittedNote = noteForSubmittedAnswer(question, state);
|
|
if (submittedNote?.trim()) {
|
|
const note = normalizedInlineInput(submittedNote);
|
|
allLines.push(
|
|
theme.fg("muted", ` Note: ${truncateToWidth(note, Math.max(1, width - 9), Ellipsis.Unicode)}`),
|
|
);
|
|
}
|
|
}
|
|
allLines.push("");
|
|
allLines.push(theme.fg("accent", `${theme.nav.cursor} ${SUBMIT_OPTION}`));
|
|
this.#submitScrollOffset = clamp(this.#submitScrollOffset, 0, Math.max(0, allLines.length - rows));
|
|
const scrollView = new ScrollView(allLines, {
|
|
height: rows,
|
|
scrollbar: "auto",
|
|
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
});
|
|
scrollView.setScrollOffset(this.#submitScrollOffset);
|
|
const rendered = scrollView.render(width);
|
|
const lines = [...rendered];
|
|
while (lines.length < rows) lines.push("");
|
|
return {
|
|
lines: lines.slice(0, rows),
|
|
scrollOffset: this.#submitScrollOffset,
|
|
indicator: this.#clipIndicator(this.#submitScrollOffset, rows, allLines.length),
|
|
};
|
|
}
|
|
|
|
#scrollOffsetForCursor(
|
|
currentOffset: number,
|
|
cursorStart: number,
|
|
cursorEnd: number,
|
|
rows: number,
|
|
totalRows: number,
|
|
manualScroll: boolean,
|
|
): number {
|
|
const maxOffset = Math.max(0, totalRows - rows);
|
|
if (maxOffset === 0) return 0;
|
|
let nextOffset = clamp(currentOffset, 0, maxOffset);
|
|
const cursorRows = cursorEnd - cursorStart;
|
|
if (manualScroll && cursorRows > rows) {
|
|
// A page must not expose another option while Enter still targets this one.
|
|
nextOffset = clamp(nextOffset, cursorStart, cursorEnd - rows);
|
|
} else if (cursorStart < nextOffset || cursorEnd > nextOffset + rows) {
|
|
nextOffset = cursorRows <= rows ? cursorEnd - rows : cursorStart;
|
|
}
|
|
return clamp(nextOffset, 0, maxOffset);
|
|
}
|
|
|
|
#clipIndicator(offset: number, rows: number, totalRows: number): string {
|
|
const above = offset > 0;
|
|
const below = offset + rows < totalRows;
|
|
if (above && below) return "↕";
|
|
if (above) return "↑";
|
|
if (below) return "↓";
|
|
return "";
|
|
}
|
|
|
|
#unansweredCount(): number {
|
|
let count = 0;
|
|
for (let index = 0; index < this.#questions.length; index++) {
|
|
const question = this.#questions[index];
|
|
const state = this.#states[index];
|
|
if (!question || !state) continue;
|
|
if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
#handleTimeout(): void {
|
|
if (this.#closed) return;
|
|
if (this.#promptActive) {
|
|
this.#timeoutExpired = true;
|
|
return;
|
|
}
|
|
this.options.onTimeout?.();
|
|
for (let index = 0; index < this.#questions.length; index++) {
|
|
const question = this.#questions[index];
|
|
const state = this.#states[index];
|
|
if (!question || !state) continue;
|
|
if (state.selectedOptions.size === 0 && state.customInput === undefined) {
|
|
const noteMatch = /^option:(\d+)$/.exec(state.noteRowKey ?? "");
|
|
const notedIndex = noteMatch ? Number.parseInt(noteMatch[1], 10) : Number.NaN;
|
|
const fallbackIndex =
|
|
Number.isInteger(notedIndex) && question.options[notedIndex]
|
|
? notedIndex
|
|
: clamp(question.recommended ?? 0, 0, Math.max(0, question.options.length - 1));
|
|
const fallback = question.options[fallbackIndex];
|
|
if (fallback) state.selectedOptions.add(fallback.label);
|
|
state.timedOut = true;
|
|
}
|
|
}
|
|
this.#finishSubmit();
|
|
}
|
|
|
|
#runDeferredTimeout(): void {
|
|
if (!this.#timeoutExpired) return;
|
|
this.#timeoutExpired = false;
|
|
this.#handleTimeout();
|
|
}
|
|
|
|
#finishSubmit(): void {
|
|
if (this.#closed) return;
|
|
this.#closed = true;
|
|
this.#countdown?.dispose();
|
|
this.callbacks.onSubmit({ kind: "submit", results: this.#buildResults() });
|
|
}
|
|
|
|
#finishCancel(): void {
|
|
if (this.#closed) return;
|
|
this.#closed = true;
|
|
this.#countdown?.dispose();
|
|
this.callbacks.onCancel();
|
|
}
|
|
|
|
#buildResults(): ExtensionAskDialogResultItem[] {
|
|
const results: ExtensionAskDialogResultItem[] = [];
|
|
for (let index = 0; index < this.#questions.length; index++) {
|
|
const question = this.#questions[index];
|
|
const state = this.#states[index];
|
|
if (!question || !state) continue;
|
|
const selectedOptions = question.options
|
|
.map(option => option.label)
|
|
.filter(label => state.selectedOptions.has(label));
|
|
results.push({
|
|
id: question.id,
|
|
question: question.question,
|
|
options: question.options.map(option => option.label),
|
|
multi: question.multi ?? false,
|
|
selectedOptions,
|
|
customInput: state.customInput,
|
|
note: noteForSubmittedAnswer(question, state),
|
|
timedOut: state.timedOut || undefined,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
}
|