@fyeeme/pi-ask-user 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -30
- package/README.md +99 -79
- package/index.ts +473 -814
- package/package.json +6 -3
- package/src/ask-dialog.ts +1115 -0
- package/src/ask-legacy.ts +642 -0
- package/src/compat.ts +104 -0
- package/src/countdown-timer.ts +83 -0
- package/src/overlay-box.ts +53 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskDialogComponent — migrated from oh-my-pi
|
|
3
|
+
* (packages/coding-agent/src/modes/components/ask-dialog.ts).
|
|
4
|
+
*
|
|
5
|
+
* The multi-question tabbed ask dialog: one tab per question (plus a Submit
|
|
6
|
+
* review tab for 3+ questions or any multi question), radio/checkbox markers,
|
|
7
|
+
* markdown/code option previews, an inactivity countdown that auto-picks
|
|
8
|
+
* recommended options, and an embedded single-line prompt for "Other" answers.
|
|
9
|
+
*
|
|
10
|
+
* Adaptations for pi's extension API (everything else is omp code):
|
|
11
|
+
* - omp's global `theme` singleton and `getMarkdownTheme()` become a pi Theme
|
|
12
|
+
* instance injected via the constructor plus `markdownThemeFrom()`.
|
|
13
|
+
* - omp's symbol table (theme.radio/checkbox/nav/boxRound) becomes SYMBOLS.
|
|
14
|
+
* - omp's TabBar component becomes a self-drawn tab chip row (pi-tui has no
|
|
15
|
+
* TabBar), visually aligned with omp's chips.
|
|
16
|
+
* - omp's ScrollView string window becomes windowLines + clipIndicator (pi's
|
|
17
|
+
* ScrollView is a layout container, not a string[] window).
|
|
18
|
+
* - omp's global keybinding matchers (matchesSelectUp/…) and editorKey labels
|
|
19
|
+
* resolve through the KeybindingsManager pi injects into ui.custom().
|
|
20
|
+
* - omp's nested HookEditorComponent prompts (onPrompt callback swapping the
|
|
21
|
+
* editor container) become an embedded prompt mode inside this component:
|
|
22
|
+
* pi extensions own a single custom component slot and cannot mount a
|
|
23
|
+
* second focused component, so the dialog itself renders the input row and
|
|
24
|
+
* handles its keystrokes while #promptActive.
|
|
25
|
+
* - The inputGuard (draft editor proxy) is dropped: pi extensions cannot
|
|
26
|
+
* access the draft editor.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { getMarkdownTheme, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
32
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
33
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, Input } from "@earendil-works/pi-tui";
|
|
34
|
+
import {
|
|
35
|
+
clamp,
|
|
36
|
+
ELLIPSIS,
|
|
37
|
+
keyLabel,
|
|
38
|
+
replaceTabs,
|
|
39
|
+
renderInlineMarkdown,
|
|
40
|
+
SYMBOLS,
|
|
41
|
+
windowLines,
|
|
42
|
+
clipIndicator,
|
|
43
|
+
} from "./compat.ts";
|
|
44
|
+
import { CountdownTimer, type TuiRenderHandle } from "./countdown-timer.ts";
|
|
45
|
+
import { bottomBorder, divider, fit, row, topBorder } from "./overlay-box.ts";import type {
|
|
46
|
+
ExtensionAskDialogOption,
|
|
47
|
+
ExtensionAskDialogQuestion,
|
|
48
|
+
ExtensionAskDialogResultItem,
|
|
49
|
+
ExtensionAskDialogSubmitResult,
|
|
50
|
+
} from "./types.ts";
|
|
51
|
+
|
|
52
|
+
const OTHER_OPTION = "Other (type your own)";
|
|
53
|
+
const SUBMIT_OPTION = "Submit";
|
|
54
|
+
|
|
55
|
+
/** Fraction of the terminal the dialog may occupy. The box height is fixed
|
|
56
|
+
* at spawn from the tallest tab's content (re-measured only on viewport
|
|
57
|
+
* resize) and clamped to this ratio; it rises from the bottom as a stable
|
|
58
|
+
* panel that never resizes on tab switches or cursor moves. */
|
|
59
|
+
const DIALOG_HEIGHT_RATIO = 0.7;
|
|
60
|
+
const MIN_DIALOG_ROWS = 12;
|
|
61
|
+
const MIN_BODY_ROWS = 5;
|
|
62
|
+
const MAX_HEADER_CHIP_WIDTH = 16;
|
|
63
|
+
/** Maximum number of wrapped lines for an in-body question header, so a long
|
|
64
|
+
* or multi-line question cannot push the option list off-screen. */
|
|
65
|
+
const MAX_HEADER_ROWS = 4;
|
|
66
|
+
/** Maximum rows of the embedded prompt view (title + input + hint) rendered
|
|
67
|
+
* inside the dialog frame while the user types an Other answer. */
|
|
68
|
+
const MAX_PROMPT_TITLE_ROWS = 3;
|
|
69
|
+
|
|
70
|
+
/** Minimum inner width for the side-by-side option/preview split. */
|
|
71
|
+
const SPLIT_MIN_WIDTH = 80;
|
|
72
|
+
|
|
73
|
+
interface AskDialogCallbacks {
|
|
74
|
+
onSubmit(result: ExtensionAskDialogSubmitResult): void;
|
|
75
|
+
onCancel(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface AskDialogOptions {
|
|
79
|
+
timeout?: number;
|
|
80
|
+
onTimeout?: () => void;
|
|
81
|
+
tui?: TuiRenderHandle;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface QuestionState {
|
|
85
|
+
selectedOptions: Set<string>;
|
|
86
|
+
customInput: string | undefined;
|
|
87
|
+
cursorIndex: number;
|
|
88
|
+
scrollOffset: number;
|
|
89
|
+
manualScroll: boolean;
|
|
90
|
+
timedOut: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type QuestionRowKind = "option" | "other";
|
|
94
|
+
|
|
95
|
+
interface QuestionRow {
|
|
96
|
+
kind: QuestionRowKind;
|
|
97
|
+
key: string;
|
|
98
|
+
label: string;
|
|
99
|
+
optionIndex: number | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface RenderedList {
|
|
103
|
+
lines: string[];
|
|
104
|
+
scrollOffset: number;
|
|
105
|
+
indicator: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface PreviewSegment {
|
|
109
|
+
kind: "markdown" | "code";
|
|
110
|
+
text: string;
|
|
111
|
+
language: string | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type PreviewRenderCache = Map<string, Map<number, readonly string[]>>;
|
|
115
|
+
|
|
116
|
+
/** Embedded prompt state (replaces omp's nested HookEditorComponent):
|
|
117
|
+
* a pi-tui Input instance owns the input line — cursor marker (IME
|
|
118
|
+
* positioning), horizontal scroll, grapheme edits, paste, undo — while the
|
|
119
|
+
* dialog keeps the question/state snapshot. */
|
|
120
|
+
interface EmbeddedPrompt {
|
|
121
|
+
title: string;
|
|
122
|
+
input: Input;
|
|
123
|
+
prefill: string | undefined;
|
|
124
|
+
/** State snapshot of the question the prompt belongs to. */
|
|
125
|
+
question: ExtensionAskDialogQuestion;
|
|
126
|
+
state: QuestionState;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stripRecommendedSuffix(label: string): string {
|
|
130
|
+
const suffix = " (Recommended)";
|
|
131
|
+
return label.endsWith(suffix) ? label.slice(0, -suffix.length) : label;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function questionTabLabel(question: ExtensionAskDialogQuestion, index: number): string {
|
|
135
|
+
const base = question.header?.trim() || question.id || `Q${index + 1}`;
|
|
136
|
+
return truncateToWidth(replaceTabs(base), MAX_HEADER_CHIP_WIDTH, ELLIPSIS);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderQuestionTitle(theme: Theme, question: ExtensionAskDialogQuestion, width: number): string[] {
|
|
140
|
+
const questionText = renderInlineMarkdown(replaceTabs(question.question), t => theme.fg("text", t));
|
|
141
|
+
const wrapped = wrapTextWithAnsi(questionText, Math.max(1, width));
|
|
142
|
+
if (wrapped.length <= MAX_HEADER_ROWS) return wrapped;
|
|
143
|
+
return [
|
|
144
|
+
...wrapped.slice(0, MAX_HEADER_ROWS - 1),
|
|
145
|
+
truncateToWidth(wrapped.slice(MAX_HEADER_ROWS - 1).join(" "), Math.max(1, width), ELLIPSIS),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function splitPreviewSegments(preview: string): PreviewSegment[] {
|
|
150
|
+
const segments: PreviewSegment[] = [];
|
|
151
|
+
const markdownBuffer: string[] = [];
|
|
152
|
+
let fenceChar: string | undefined;
|
|
153
|
+
let fenceLength = 0;
|
|
154
|
+
let fenceLanguage: string | undefined;
|
|
155
|
+
let codeBuffer: string[] = [];
|
|
156
|
+
|
|
157
|
+
const flushMarkdown = (): void => {
|
|
158
|
+
if (markdownBuffer.length === 0) return;
|
|
159
|
+
segments.push({ kind: "markdown", text: markdownBuffer.join("\n"), language: undefined });
|
|
160
|
+
markdownBuffer.length = 0;
|
|
161
|
+
};
|
|
162
|
+
const flushCode = (): void => {
|
|
163
|
+
segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
|
|
164
|
+
codeBuffer = [];
|
|
165
|
+
fenceChar = undefined;
|
|
166
|
+
fenceLength = 0;
|
|
167
|
+
fenceLanguage = undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const line of replaceTabs(preview).split("\n")) {
|
|
171
|
+
const fenceMatch = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line);
|
|
172
|
+
if (fenceChar !== undefined) {
|
|
173
|
+
if (fenceMatch) {
|
|
174
|
+
const marker = fenceMatch[2] ?? "";
|
|
175
|
+
const info = fenceMatch[3]?.trim() ?? "";
|
|
176
|
+
if (marker.startsWith(fenceChar) && marker.length >= fenceLength && info === "") {
|
|
177
|
+
flushCode();
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
codeBuffer.push(line);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (fenceMatch) {
|
|
185
|
+
flushMarkdown();
|
|
186
|
+
const marker = fenceMatch[2] ?? "";
|
|
187
|
+
fenceChar = marker[0];
|
|
188
|
+
fenceLength = marker.length;
|
|
189
|
+
fenceLanguage = fenceMatch[3]?.trim().split(/\s+/, 1)[0] || undefined;
|
|
190
|
+
codeBuffer = [];
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
markdownBuffer.push(line);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (fenceChar !== undefined) {
|
|
197
|
+
segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
|
|
198
|
+
} else {
|
|
199
|
+
flushMarkdown();
|
|
200
|
+
}
|
|
201
|
+
return segments;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderPreviewContent(theme: Theme, preview: string, width: number): string[] {
|
|
205
|
+
const out: string[] = [];
|
|
206
|
+
const mdTheme: MarkdownTheme = getMarkdownTheme();
|
|
207
|
+
const accentStyle = { color: (text: string) => theme.fg("muted", text) };
|
|
208
|
+
for (const segment of splitPreviewSegments(preview)) {
|
|
209
|
+
if (segment.kind === "code") {
|
|
210
|
+
// pi's highlightCode returns ANSI-styled lines (same signature as
|
|
211
|
+
// omp's); overflow is clipped by the surrounding row() fit().
|
|
212
|
+
out.push(...highlightCode(segment.text, segment.language));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const markdown = new Markdown(segment.text, 0, 0, mdTheme, accentStyle);
|
|
216
|
+
out.push(...markdown.render(Math.max(1, width)));
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Cached preview render at a given width. Returns raw lines; callers add
|
|
222
|
+
* their own gutter (inline mode indents under the option, split mode draws
|
|
223
|
+
* the right pane). */
|
|
224
|
+
function renderCachedPreview(theme: Theme, cache: PreviewRenderCache, preview: string, width: number): readonly string[] {
|
|
225
|
+
let byWidth = cache.get(preview);
|
|
226
|
+
if (!byWidth) {
|
|
227
|
+
byWidth = new Map();
|
|
228
|
+
cache.set(preview, byWidth);
|
|
229
|
+
}
|
|
230
|
+
let rendered = byWidth.get(width);
|
|
231
|
+
if (!rendered) {
|
|
232
|
+
rendered = renderPreviewContent(theme, preview, width);
|
|
233
|
+
byWidth.set(width, rendered);
|
|
234
|
+
}
|
|
235
|
+
return rendered;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizedInlineInput(input: string): string {
|
|
239
|
+
return replaceTabs(input).replace(/\s+/g, " ").trim();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderAnswerSummary(theme: Theme, question: ExtensionAskDialogQuestion, state: QuestionState): string {
|
|
243
|
+
const selected = question.options.map(option => option.label).filter(label => state.selectedOptions.has(label));
|
|
244
|
+
if (question.multi) {
|
|
245
|
+
const answers = [...selected];
|
|
246
|
+
if (state.customInput !== undefined) answers.push(`Other: “${normalizedInlineInput(state.customInput)}”`);
|
|
247
|
+
return answers.length > 0 ? answers.join(", ") : theme.fg("warning", "unanswered");
|
|
248
|
+
}
|
|
249
|
+
if (state.customInput !== undefined) return `“${normalizedInlineInput(state.customInput)}”`;
|
|
250
|
+
if (selected.length === 0) return theme.fg("warning", "unanswered");
|
|
251
|
+
return selected[0] ?? theme.fg("warning", "unanswered");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function optionMarker(theme: Theme, question: ExtensionAskDialogQuestion, checked: boolean): string {
|
|
255
|
+
if (question.multi) return checked ? SYMBOLS.checkbox.checked : SYMBOLS.checkbox.unchecked;
|
|
256
|
+
return checked ? SYMBOLS.radio.selected : SYMBOLS.radio.unselected;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderRowLabel(
|
|
260
|
+
theme: Theme,
|
|
261
|
+
rowItem: QuestionRow,
|
|
262
|
+
question: ExtensionAskDialogQuestion,
|
|
263
|
+
state: QuestionState,
|
|
264
|
+
selected: boolean,
|
|
265
|
+
previewCache: PreviewRenderCache,
|
|
266
|
+
width: number,
|
|
267
|
+
showInlinePreview: boolean,
|
|
268
|
+
): string[] {
|
|
269
|
+
const isOption = rowItem.kind === "option";
|
|
270
|
+
const isOther = rowItem.kind === "other";
|
|
271
|
+
const checked = isOption
|
|
272
|
+
? state.selectedOptions.has(stripRecommendedSuffix(rowItem.label))
|
|
273
|
+
: isOther && state.customInput !== undefined;
|
|
274
|
+
const color = selected ? "accent" : checked ? "toolOutput" : "text";
|
|
275
|
+
const marker = `${theme.fg(checked ? "success" : "dim", optionMarker(theme, question, checked))} `;
|
|
276
|
+
const cursor = selected ? theme.fg("accent", `${SYMBOLS.nav.cursor} `) : " ";
|
|
277
|
+
const label = renderInlineMarkdown(rowItem.label, t => theme.fg(color, t));
|
|
278
|
+
const labelWidth = Math.max(1, width - visibleWidth(cursor) - visibleWidth(marker));
|
|
279
|
+
const wrappedLabel = wrapTextWithAnsi(label, labelWidth);
|
|
280
|
+
const indent = " ".repeat(visibleWidth(cursor) + visibleWidth(marker));
|
|
281
|
+
const lines = [`${cursor}${marker}${wrappedLabel[0] ?? ""}`];
|
|
282
|
+
for (let i = 1; i < wrappedLabel.length; i++) {
|
|
283
|
+
lines.push(`${indent}${wrappedLabel[i] ?? ""}`);
|
|
284
|
+
}
|
|
285
|
+
if (rowItem.kind === "option") {
|
|
286
|
+
const option = question.options[rowItem.optionIndex ?? -1];
|
|
287
|
+
if (option?.description?.trim()) {
|
|
288
|
+
const description = renderInlineMarkdown(option.description.trim(), t => theme.fg("muted", t));
|
|
289
|
+
const wrapped = wrapTextWithAnsi(description, Math.max(1, width - 6));
|
|
290
|
+
for (const line of wrapped.slice(0, 2)) {
|
|
291
|
+
lines.push(` ${truncateToWidth(line, Math.max(1, width - 6), ELLIPSIS)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (option?.preview?.trim() && showInlinePreview) {
|
|
295
|
+
const previewWidth = Math.max(1, width - 8);
|
|
296
|
+
lines.push(
|
|
297
|
+
...renderCachedPreview(theme, previewCache, option.preview, previewWidth).map(
|
|
298
|
+
line => ` ${theme.fg("borderMuted", "│")} ${line}`,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (isOther && state.customInput !== undefined) {
|
|
304
|
+
const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
|
|
305
|
+
lines.push(theme.fg("muted", ` ${truncateToWidth(preview, Math.max(1, width - 6), ELLIPSIS)}`));
|
|
306
|
+
}
|
|
307
|
+
return lines;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Coerce untrusted dialog questions into a render-safe shape. The live ask
|
|
312
|
+
* dialog is reached from streamed tool args, where a question entry can arrive
|
|
313
|
+
* with a missing or non-string `question` field. The render helpers assume
|
|
314
|
+
* strings, so a malformed entry would throw and take down the whole TUI render
|
|
315
|
+
* loop — normalize first. (omp ask-dialog.ts)
|
|
316
|
+
*/
|
|
317
|
+
function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogQuestion[] {
|
|
318
|
+
if (!Array.isArray(questions)) return [];
|
|
319
|
+
const out: ExtensionAskDialogQuestion[] = [];
|
|
320
|
+
for (const entry of questions) {
|
|
321
|
+
if (!entry || typeof entry !== "object") continue;
|
|
322
|
+
const q = entry as Partial<ExtensionAskDialogQuestion>;
|
|
323
|
+
const options: ExtensionAskDialogOption[] = [];
|
|
324
|
+
if (Array.isArray(q.options)) {
|
|
325
|
+
for (const opt of q.options) {
|
|
326
|
+
if (!opt || typeof opt !== "object") continue;
|
|
327
|
+
const o = opt as Partial<ExtensionAskDialogOption>;
|
|
328
|
+
options.push({
|
|
329
|
+
label: typeof o.label === "string" ? o.label : "",
|
|
330
|
+
...(typeof o.description === "string" ? { description: o.description } : {}),
|
|
331
|
+
...(typeof o.preview === "string" ? { preview: o.preview } : {}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
out.push({
|
|
336
|
+
id: typeof q.id === "string" ? q.id : "?",
|
|
337
|
+
question: typeof q.question === "string" ? q.question : "",
|
|
338
|
+
...(typeof q.header === "string" ? { header: q.header } : {}),
|
|
339
|
+
options,
|
|
340
|
+
...(typeof q.multi === "boolean" ? { multi: q.multi } : {}),
|
|
341
|
+
...(Number.isInteger(q.recommended) ? { recommended: q.recommended } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Bound a prompt title to a fixed row budget (omp boundPromptTitle). */
|
|
348
|
+
function boundPromptTitle(theme: Theme, prefix: string, question: string): string[] {
|
|
349
|
+
const cols = process.stdout.columns ?? 80;
|
|
350
|
+
const width = Math.max(1, cols - 4);
|
|
351
|
+
const flat = `${prefix}${normalizedInlineInput(question)}`;
|
|
352
|
+
const wrapped = wrapTextWithAnsi(theme.fg("text", flat), width);
|
|
353
|
+
if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped;
|
|
354
|
+
const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
|
|
355
|
+
const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, ELLIPSIS);
|
|
356
|
+
return [...kept, last];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export class AskDialogComponent {
|
|
360
|
+
/** Focusable (IME support, pi-tui contract): the TUI sets `focused` when
|
|
361
|
+
* this component gains keyboard focus. While the embedded prompt is
|
|
362
|
+
* open, focus propagates to its pi-tui Input so CURSOR_MARKER (and with
|
|
363
|
+
* it the hardware cursor / IME candidate window) tracks the input point;
|
|
364
|
+
* outside the prompt the dialog renders no marker and keeps the cursor
|
|
365
|
+
* hidden. */
|
|
366
|
+
#focused = false;
|
|
367
|
+
get focused(): boolean {
|
|
368
|
+
return this.#focused;
|
|
369
|
+
}
|
|
370
|
+
set focused(value: boolean) {
|
|
371
|
+
this.#focused = value;
|
|
372
|
+
if (this.#embeddedPrompt) this.#embeddedPrompt.input.focused = value;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#states: QuestionState[];
|
|
376
|
+
#activeTabIndex = 0;
|
|
377
|
+
#submitScrollOffset = 0;
|
|
378
|
+
#bodyRows = MIN_BODY_ROWS;
|
|
379
|
+
#questionCanPage = false;
|
|
380
|
+
#remainingSeconds: number | undefined;
|
|
381
|
+
#countdown: CountdownTimer | undefined;
|
|
382
|
+
#promptActive = false;
|
|
383
|
+
#embeddedPrompt: EmbeddedPrompt | undefined;
|
|
384
|
+
#timeoutExpired = false;
|
|
385
|
+
#closed = false;
|
|
386
|
+
#stableHeight: { key: string; total: number } | undefined;
|
|
387
|
+
#previewCache: PreviewRenderCache = new Map();
|
|
388
|
+
readonly #questions: ExtensionAskDialogQuestion[];
|
|
389
|
+
readonly #theme: Theme;
|
|
390
|
+
readonly #keybindings: KeybindingsManager;
|
|
391
|
+
readonly #callbacks: AskDialogCallbacks;
|
|
392
|
+
readonly #options: AskDialogOptions;
|
|
393
|
+
|
|
394
|
+
constructor(
|
|
395
|
+
theme: Theme,
|
|
396
|
+
keybindings: KeybindingsManager,
|
|
397
|
+
questions: ExtensionAskDialogQuestion[],
|
|
398
|
+
callbacks: AskDialogCallbacks,
|
|
399
|
+
options: AskDialogOptions = {},
|
|
400
|
+
) {
|
|
401
|
+
this.#theme = theme;
|
|
402
|
+
this.#keybindings = keybindings;
|
|
403
|
+
this.#callbacks = callbacks;
|
|
404
|
+
this.#options = options;
|
|
405
|
+
this.#questions = normalizeDialogQuestions(questions);
|
|
406
|
+
this.#states = this.#questions.map(question => {
|
|
407
|
+
const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
|
|
408
|
+
const maxIndex = Math.max(0, question.options.length - 1);
|
|
409
|
+
return {
|
|
410
|
+
selectedOptions: new Set<string>(),
|
|
411
|
+
customInput: undefined,
|
|
412
|
+
cursorIndex: clamp(recommended ?? 0, 0, maxIndex),
|
|
413
|
+
scrollOffset: 0,
|
|
414
|
+
manualScroll: false,
|
|
415
|
+
timedOut: false,
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
if (options.timeout && options.timeout > 0) {
|
|
419
|
+
this.#countdown = new CountdownTimer(
|
|
420
|
+
options.timeout,
|
|
421
|
+
options.tui,
|
|
422
|
+
seconds => {
|
|
423
|
+
this.#remainingSeconds = seconds;
|
|
424
|
+
},
|
|
425
|
+
() => this.#handleTimeout(),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
invalidate(): void {
|
|
431
|
+
this.#stableHeight = undefined;
|
|
432
|
+
this.#previewCache.clear();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
dispose(): void {
|
|
436
|
+
this.#closed = true;
|
|
437
|
+
this.#countdown?.dispose();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
handleInput(keyData: string): void {
|
|
441
|
+
if (this.#closed) return;
|
|
442
|
+
if (this.#promptActive) {
|
|
443
|
+
this.#handleEmbeddedPromptInput(keyData);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Reset the inactivity countdown on any key that reaches past the
|
|
447
|
+
// closed/prompt guards, matching HookSelector/HookInput semantics.
|
|
448
|
+
this.#countdown?.reset();
|
|
449
|
+
if (this.#keybindings.matches(keyData, "tui.select.cancel")) {
|
|
450
|
+
this.#finishCancel();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (this.#hasTabBar() && this.#handleTabSwitchKey(keyData)) {
|
|
454
|
+
this.#requestRender();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (this.#isSubmitTab()) {
|
|
458
|
+
this.#handleSubmitTabInput(keyData);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.#handleQuestionInput(keyData);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
render(width: number): string[] {
|
|
465
|
+
const theme = this.#theme;
|
|
466
|
+
const innerWidth = Math.max(1, width - 4);
|
|
467
|
+
// Fixed panel height: measured from the tallest tab at spawn and
|
|
468
|
+
// re-measured only when the viewport changes. Tab switches, cursor
|
|
469
|
+
// moves, and later answers never resize the box; content that
|
|
470
|
+
// outgrows it scrolls.
|
|
471
|
+
const totalRows = this.#dialogHeight(innerWidth, process.stdout.rows || 40);
|
|
472
|
+
const headerLines = this.#renderHeader(innerWidth);
|
|
473
|
+
const fixedRows = 1 + headerLines.length + 1 + 1 + 1 + 1;
|
|
474
|
+
const bodyRows = Math.max(MIN_BODY_ROWS, totalRows - fixedRows);
|
|
475
|
+
this.#bodyRows = bodyRows;
|
|
476
|
+
const bodyLines = this.#promptActive
|
|
477
|
+
? this.#renderEmbeddedPromptBody(innerWidth, bodyRows)
|
|
478
|
+
: this.#isSubmitTab()
|
|
479
|
+
? this.#renderSubmitBody(innerWidth, bodyRows)
|
|
480
|
+
: this.#renderQuestionBody(innerWidth, bodyRows);
|
|
481
|
+
const footer = this.#footerHintText(bodyLines.indicator);
|
|
482
|
+
return [
|
|
483
|
+
topBorder(theme, width, this.#promptActive ? "Ask — input" : this.#titleText()),
|
|
484
|
+
...headerLines.map(line => row(theme, line, width)),
|
|
485
|
+
divider(theme, width),
|
|
486
|
+
...bodyLines.lines.map(line => row(theme, line, width)),
|
|
487
|
+
divider(theme, width),
|
|
488
|
+
row(theme, theme.fg("dim", footer), width),
|
|
489
|
+
bottomBorder(theme, width),
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#dialogHeight(width: number, termRows: number): number {
|
|
494
|
+
const key = `${width}:${termRows}`;
|
|
495
|
+
if (this.#stableHeight?.key === key) return this.#stableHeight.total;
|
|
496
|
+
const total = this.#measureHeight(width, termRows);
|
|
497
|
+
this.#stableHeight = { key, total };
|
|
498
|
+
return total;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Measure the tallest tab's natural content height, clamped to
|
|
502
|
+
* DIALOG_HEIGHT_RATIO of the terminal. Derived from questions and
|
|
503
|
+
* viewport only — never from cursor, tab, or answer state — so the box
|
|
504
|
+
* size is stable for the dialog's lifetime at a given terminal size. */
|
|
505
|
+
#measureHeight(width: number, termRows: number): number {
|
|
506
|
+
const maxHeight = Math.max(MIN_DIALOG_ROWS, Math.floor(termRows * DIALOG_HEIGHT_RATIO));
|
|
507
|
+
const chrome = 5; // topBorder + divider + divider + footer + bottomBorder
|
|
508
|
+
const tabBarRows = this.#hasTabBar() ? 1 : 0;
|
|
509
|
+
let needed = MIN_DIALOG_ROWS;
|
|
510
|
+
for (let index = 0; index < this.#questions.length; index++) {
|
|
511
|
+
const question = this.#questions[index];
|
|
512
|
+
const state = this.#states[index];
|
|
513
|
+
if (!question || !state) continue;
|
|
514
|
+
const headerRows = tabBarRows + renderQuestionTitle(this.#theme, question, width).length;
|
|
515
|
+
const rowItems = this.#questionRows(question);
|
|
516
|
+
let body = 0;
|
|
517
|
+
const split = this.#useSplitLayout(question, width);
|
|
518
|
+
const { leftWidth, rightWidth } = this.#splitWidths(width);
|
|
519
|
+
for (const rowItem of rowItems) {
|
|
520
|
+
body += renderRowLabel(
|
|
521
|
+
this.#theme,
|
|
522
|
+
rowItem,
|
|
523
|
+
question,
|
|
524
|
+
state,
|
|
525
|
+
false,
|
|
526
|
+
this.#previewCache,
|
|
527
|
+
split ? leftWidth : width,
|
|
528
|
+
!split,
|
|
529
|
+
).length;
|
|
530
|
+
}
|
|
531
|
+
if (split) {
|
|
532
|
+
// The right pane must fit the tallest preview at its width.
|
|
533
|
+
let previewRows = 0;
|
|
534
|
+
for (const option of question.options) {
|
|
535
|
+
if (option.preview?.trim()) {
|
|
536
|
+
previewRows = Math.max(
|
|
537
|
+
previewRows,
|
|
538
|
+
renderCachedPreview(this.#theme, this.#previewCache, option.preview, rightWidth).length,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
body = Math.max(body, previewRows);
|
|
543
|
+
}
|
|
544
|
+
needed = Math.max(needed, chrome + headerRows + Math.max(MIN_BODY_ROWS, body));
|
|
545
|
+
}
|
|
546
|
+
if (this.#hasSubmitTab()) {
|
|
547
|
+
const body = 2 + this.#questions.length + 2;
|
|
548
|
+
needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
|
|
549
|
+
}
|
|
550
|
+
return Math.min(needed, maxHeight);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#titleText(): string {
|
|
554
|
+
return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#hasTabBar(): boolean {
|
|
558
|
+
// Multi-question dialogs always get a tab bar for ←/→ navigation, even
|
|
559
|
+
// when there is no Submit review tab to switch to; a Submit tab alone
|
|
560
|
+
// (single multi question) also needs its chip row.
|
|
561
|
+
return this.#questions.length > 1 || this.#hasSubmitTab();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
#hasSubmitTab(): boolean {
|
|
565
|
+
// The Submit review tab appears for 3+ questions or any multi question.
|
|
566
|
+
// Multi questions confirm on the Submit tab (Enter toggles, never
|
|
567
|
+
// submits), so any multi question forces the tab even when there is
|
|
568
|
+
// only one question. Fewer than three single-select questions advance
|
|
569
|
+
// Enter-to-submit without a review page.
|
|
570
|
+
return this.#questions.length >= 3 || this.#questions.some(question => question.multi);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
#submitTabIndex(): number {
|
|
574
|
+
return this.#questions.length;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#isSubmitTab(): boolean {
|
|
578
|
+
return this.#hasSubmitTab() && this.#activeTabIndex === this.#submitTabIndex();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#currentQuestionIndex(): number {
|
|
582
|
+
return clamp(this.#activeTabIndex, 0, Math.max(0, this.#questions.length - 1));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#requestRender(): void {
|
|
586
|
+
this.#options.tui?.requestRender();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#renderHeader(width: number): string[] {
|
|
590
|
+
const theme = this.#theme;
|
|
591
|
+
const lines: string[] = [];
|
|
592
|
+
if (this.#hasTabBar()) {
|
|
593
|
+
lines.push(...this.#renderTabBar(width));
|
|
594
|
+
}
|
|
595
|
+
if (this.#isSubmitTab()) {
|
|
596
|
+
lines.push(theme.bold(theme.fg("accent", "Review answers")));
|
|
597
|
+
return lines;
|
|
598
|
+
}
|
|
599
|
+
const questionIndex = this.#currentQuestionIndex();
|
|
600
|
+
const question = this.#questions[questionIndex];
|
|
601
|
+
if (!question) return lines;
|
|
602
|
+
lines.push(...renderQuestionTitle(theme, question, width));
|
|
603
|
+
return lines;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Self-drawn tab chip row standing in for omp's TabBar component. */
|
|
607
|
+
#renderTabBar(width: number): string[] {
|
|
608
|
+
const theme = this.#theme;
|
|
609
|
+
const chips: string[] = [];
|
|
610
|
+
this.#questions.forEach((question, index) => {
|
|
611
|
+
const label = questionTabLabel(question, index);
|
|
612
|
+
const active = index === this.#activeTabIndex;
|
|
613
|
+
const answered = this.#states[index]?.selectedOptions.size || this.#states[index]?.customInput !== undefined;
|
|
614
|
+
const dot = answered ? theme.fg("success", "●") : theme.fg("dim", "○");
|
|
615
|
+
chips.push(active ? theme.fg("accent", `${SYMBOLS.nav.cursor} ${label} `) : `${dot}${theme.fg("muted", ` ${label} `)}`);
|
|
616
|
+
});
|
|
617
|
+
if (this.#hasSubmitTab()) {
|
|
618
|
+
if (this.#isSubmitTab()) {
|
|
619
|
+
chips.push(theme.fg("accent", `${SYMBOLS.nav.cursor} ${SUBMIT_OPTION} `));
|
|
620
|
+
} else {
|
|
621
|
+
chips.push(theme.fg("muted", ` ${SUBMIT_OPTION} `));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const joined = chips.join("");
|
|
625
|
+
return [truncateToWidth(joined, Math.max(1, width), "")];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
#footerHintText(indicator: string): string {
|
|
629
|
+
const theme = this.#theme;
|
|
630
|
+
const cancelKey = keyLabel(this.#keybindings.getKeys("tui.select.cancel"), "esc");
|
|
631
|
+
const cancel = `${cancelKey} cancel`;
|
|
632
|
+
if (this.#promptActive) {
|
|
633
|
+
return `Enter confirm · Esc back · ${cancel}`;
|
|
634
|
+
}
|
|
635
|
+
if (this.#isSubmitTab()) {
|
|
636
|
+
const scroll = indicator ? ` ${indicator} scroll ·` : "";
|
|
637
|
+
return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
|
|
638
|
+
}
|
|
639
|
+
const question = this.#questions[this.#currentQuestionIndex()];
|
|
640
|
+
// Enter advances in multi-question dialogs and submits on the last
|
|
641
|
+
// question when there is no review tab.
|
|
642
|
+
const isLast = this.#currentQuestionIndex() >= this.#questions.length - 1;
|
|
643
|
+
const enterAction = !this.#hasSubmitTab() && isLast ? "submit" : "next";
|
|
644
|
+
const action = question?.multi ? `Space toggle · Enter ${enterAction}` : "Enter select";
|
|
645
|
+
const tabs = this.#hasTabBar() ? " · Tab/←/→" : "";
|
|
646
|
+
if (this.#questionCanPage && indicator) {
|
|
647
|
+
const pageUp = keyLabel(this.#keybindings.getKeys("tui.select.pageUp"), "PgUp");
|
|
648
|
+
const pageDown = keyLabel(this.#keybindings.getKeys("tui.select.pageDown"), "PgDn");
|
|
649
|
+
return `${action} · ↑/↓${tabs} · ${cancel} · ${pageUp}/${pageDown} ${indicator}`;
|
|
650
|
+
}
|
|
651
|
+
const scroll = indicator ? ` ${indicator} scroll ·` : "";
|
|
652
|
+
return `${action} · ↑/↓ move${tabs} ·${scroll} ${cancel}`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
#questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] {
|
|
656
|
+
const rows: QuestionRow[] = question.options.map((option, index) => ({
|
|
657
|
+
kind: "option",
|
|
658
|
+
key: `option:${index}`,
|
|
659
|
+
label: this.#optionLabel(question, option.label, index),
|
|
660
|
+
optionIndex: index,
|
|
661
|
+
}));
|
|
662
|
+
rows.push({ kind: "other", key: "other", label: OTHER_OPTION, optionIndex: undefined });
|
|
663
|
+
return rows;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#optionLabel(question: ExtensionAskDialogQuestion, label: string, index: number): string {
|
|
667
|
+
return question.recommended === index ? `${label} (Recommended)` : label;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
|
|
671
|
+
const question = this.#questions[this.#currentQuestionIndex()];
|
|
672
|
+
const state = this.#states[this.#currentQuestionIndex()];
|
|
673
|
+
if (!question || !state) return undefined;
|
|
674
|
+
return { question, state };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
#handleTabSwitchKey(keyData: string): boolean {
|
|
678
|
+
const switchTab = (direction: 1 | -1): void => {
|
|
679
|
+
const tabCount = this.#questions.length + (this.#hasSubmitTab() ? 1 : 0);
|
|
680
|
+
this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
|
|
681
|
+
this.#submitScrollOffset = 0;
|
|
682
|
+
};
|
|
683
|
+
if (matchesKey(keyData, Key.tab) || matchesKey(keyData, Key.right)) {
|
|
684
|
+
switchTab(1);
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
if (matchesKey(keyData, "shift+tab") || matchesKey(keyData, Key.left)) {
|
|
688
|
+
switchTab(-1);
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
#handleQuestionInput(keyData: string): void {
|
|
695
|
+
const active = this.#activeQuestionState();
|
|
696
|
+
if (!active) return;
|
|
697
|
+
const { question, state } = active;
|
|
698
|
+
const rows = this.#questionRows(question);
|
|
699
|
+
if (this.#keybindings.matches(keyData, "tui.select.pageUp")) {
|
|
700
|
+
state.scrollOffset = Math.max(0, state.scrollOffset - Math.max(1, this.#bodyRows - 1));
|
|
701
|
+
state.manualScroll = true;
|
|
702
|
+
this.#requestRender();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (this.#keybindings.matches(keyData, "tui.select.pageDown")) {
|
|
706
|
+
state.scrollOffset += Math.max(1, this.#bodyRows - 1);
|
|
707
|
+
state.manualScroll = true;
|
|
708
|
+
this.#requestRender();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (this.#keybindings.matches(keyData, "tui.select.up")) {
|
|
712
|
+
state.cursorIndex = clamp(state.cursorIndex - 1, 0, Math.max(0, rows.length - 1));
|
|
713
|
+
state.manualScroll = false;
|
|
714
|
+
this.#requestRender();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (this.#keybindings.matches(keyData, "tui.select.down")) {
|
|
718
|
+
state.cursorIndex = clamp(state.cursorIndex + 1, 0, Math.max(0, rows.length - 1));
|
|
719
|
+
state.manualScroll = false;
|
|
720
|
+
this.#requestRender();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const rowItem = rows[state.cursorIndex];
|
|
724
|
+
if (!rowItem) return;
|
|
725
|
+
const isEnter = this.#keybindings.matches(keyData, "tui.select.confirm") || keyData === "\n";
|
|
726
|
+
const isSpace = matchesKey(keyData, Key.space) || keyData === " ";
|
|
727
|
+
if (!isEnter && !(question.multi && isSpace)) return;
|
|
728
|
+
if (rowItem.kind === "other") {
|
|
729
|
+
this.#openEmbeddedPrompt(question, state);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const option = question.options[rowItem.optionIndex ?? -1];
|
|
733
|
+
if (!option) return;
|
|
734
|
+
if (question.multi) {
|
|
735
|
+
if (isEnter) {
|
|
736
|
+
// Enter confirms the current selection without toggling the
|
|
737
|
+
// focused option; Space toggles. Advances to the next question
|
|
738
|
+
// (submitting only when no review tab remains).
|
|
739
|
+
this.#advanceAfterQuestion();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (state.selectedOptions.has(option.label)) {
|
|
743
|
+
state.selectedOptions.delete(option.label);
|
|
744
|
+
} else {
|
|
745
|
+
state.selectedOptions.add(option.label);
|
|
746
|
+
}
|
|
747
|
+
this.#requestRender();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
state.selectedOptions = new Set([option.label]);
|
|
751
|
+
state.customInput = undefined;
|
|
752
|
+
this.#advanceAfterQuestion();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#handleSubmitTabInput(keyData: string): void {
|
|
756
|
+
if (this.#keybindings.matches(keyData, "tui.select.up")) {
|
|
757
|
+
this.#submitScrollOffset = Math.max(0, this.#submitScrollOffset - 1);
|
|
758
|
+
this.#requestRender();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (this.#keybindings.matches(keyData, "tui.select.down")) {
|
|
762
|
+
// Clamped against the rendered line count in #renderSubmitBody.
|
|
763
|
+
this.#submitScrollOffset += 1;
|
|
764
|
+
this.#requestRender();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const isEnter = this.#keybindings.matches(keyData, "tui.select.confirm") || keyData === "\n";
|
|
768
|
+
if (isEnter) this.#finishSubmit();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#advanceAfterQuestion(): void {
|
|
772
|
+
const current = this.#currentQuestionIndex();
|
|
773
|
+
if (this.#hasSubmitTab()) {
|
|
774
|
+
this.#activeTabIndex = current + 1 < this.#submitTabIndex() ? current + 1 : this.#submitTabIndex();
|
|
775
|
+
this.#submitScrollOffset = 0;
|
|
776
|
+
this.#requestRender();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (current + 1 < this.#questions.length) {
|
|
780
|
+
this.#activeTabIndex = current + 1;
|
|
781
|
+
this.#requestRender();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
this.#finishSubmit();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// --- embedded prompt (replaces omp's nested HookEditorComponent) --------
|
|
788
|
+
|
|
789
|
+
#openEmbeddedPrompt(question: ExtensionAskDialogQuestion, state: QuestionState): void {
|
|
790
|
+
const prefix = "Custom answer: ";
|
|
791
|
+
const prefill = state.customInput;
|
|
792
|
+
const input = new Input();
|
|
793
|
+
if (prefill !== undefined) input.setValue(prefill);
|
|
794
|
+
input.onSubmit = value => this.#submitEmbeddedPrompt(value);
|
|
795
|
+
input.onEscape = () => this.#closeEmbeddedPrompt();
|
|
796
|
+
input.focused = this.#focused;
|
|
797
|
+
this.#promptActive = true;
|
|
798
|
+
this.#embeddedPrompt = {
|
|
799
|
+
title: boundPromptTitle(this.#theme, prefix, question.question).join("\n"),
|
|
800
|
+
input,
|
|
801
|
+
prefill,
|
|
802
|
+
question,
|
|
803
|
+
state,
|
|
804
|
+
};
|
|
805
|
+
this.#requestRender();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#handleEmbeddedPromptInput(keyData: string): void {
|
|
809
|
+
// The embedded pi-tui Input owns the full input semantics (grapheme
|
|
810
|
+
// edits, paste buffering, kitty CSI-u, undo, kill ring) and emits
|
|
811
|
+
// CURSOR_MARKER at the cursor for IME candidate-window positioning.
|
|
812
|
+
this.#embeddedPrompt?.input.handleInput(keyData);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
#submitEmbeddedPrompt(input: string): void {
|
|
816
|
+
const prompt = this.#embeddedPrompt;
|
|
817
|
+
if (!prompt) return;
|
|
818
|
+
const { question, state } = prompt;
|
|
819
|
+
prompt.input.focused = false;
|
|
820
|
+
this.#promptActive = false;
|
|
821
|
+
this.#embeddedPrompt = undefined;
|
|
822
|
+
// omp ordering (#promptForCustomInput): the input is applied to state
|
|
823
|
+
// first — and Enter may advance/submit — with the deferred timeout only
|
|
824
|
+
// running afterwards, so a countdown that expired mid-prompt sees the
|
|
825
|
+
// just-typed answer instead of discarding it and force-picking.
|
|
826
|
+
try {
|
|
827
|
+
if (input.trim() === "") {
|
|
828
|
+
// Submitting an empty value unselects the custom answer.
|
|
829
|
+
state.customInput = undefined;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
state.customInput = input;
|
|
833
|
+
if (!question.multi) {
|
|
834
|
+
state.selectedOptions.clear();
|
|
835
|
+
this.#advanceAfterQuestion();
|
|
836
|
+
}
|
|
837
|
+
} finally {
|
|
838
|
+
this.#runDeferredTimeout();
|
|
839
|
+
this.#requestRender();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
#closeEmbeddedPrompt(): void {
|
|
844
|
+
if (this.#embeddedPrompt) this.#embeddedPrompt.input.focused = false;
|
|
845
|
+
this.#promptActive = false;
|
|
846
|
+
this.#embeddedPrompt = undefined;
|
|
847
|
+
this.#runDeferredTimeout();
|
|
848
|
+
this.#requestRender();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#renderEmbeddedPromptBody(width: number, rows: number): RenderedList {
|
|
852
|
+
const theme = this.#theme;
|
|
853
|
+
const prompt = this.#embeddedPrompt;
|
|
854
|
+
if (!prompt) return { lines: windowLines([], 0, rows), scrollOffset: 0, indicator: "" };
|
|
855
|
+
const lines: string[] = [];
|
|
856
|
+
for (const titleLine of prompt.title.split("\n")) {
|
|
857
|
+
lines.push(theme.fg("text", titleLine));
|
|
858
|
+
}
|
|
859
|
+
lines.push("");
|
|
860
|
+
// pi-tui Input renders the input line itself: "> " prompt, horizontal
|
|
861
|
+
// scrolling, reverse-video fake cursor, and CURSOR_MARKER at the input
|
|
862
|
+
// point (zero-width) so the TUI positions the hardware cursor there —
|
|
863
|
+
// that is what keeps the IME candidate window anchored.
|
|
864
|
+
lines.push(prompt.input.render(width)[0] ?? "");
|
|
865
|
+
if (prompt.prefill !== undefined) {
|
|
866
|
+
lines.push(theme.fg("dim", "Enter keeps the value above when unchanged; empty clears it"));
|
|
867
|
+
}
|
|
868
|
+
const window = windowLines(lines, 0, rows);
|
|
869
|
+
return { lines: window, scrollOffset: 0, indicator: "" };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// --- body rendering -----------------------------------------------------
|
|
873
|
+
|
|
874
|
+
#renderQuestionBody(width: number, maxRows: number): RenderedList {
|
|
875
|
+
const active = this.#activeQuestionState();
|
|
876
|
+
if (!active) return { lines: windowLines([], 0, maxRows), scrollOffset: 0, indicator: "" };
|
|
877
|
+
const { question, state } = active;
|
|
878
|
+
const rowItems = this.#questionRows(question);
|
|
879
|
+
state.cursorIndex = clamp(state.cursorIndex, 0, Math.max(0, rowItems.length - 1));
|
|
880
|
+
return this.#renderQuestionList(question, state, rowItems, width, maxRows);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
#renderQuestionList(
|
|
884
|
+
question: ExtensionAskDialogQuestion,
|
|
885
|
+
state: QuestionState,
|
|
886
|
+
rowItems: QuestionRow[],
|
|
887
|
+
width: number,
|
|
888
|
+
rows: number,
|
|
889
|
+
): RenderedList {
|
|
890
|
+
const allLines: string[] = [];
|
|
891
|
+
const lineStartByRow: number[] = [];
|
|
892
|
+
const split = this.#useSplitLayout(question, width);
|
|
893
|
+
const leftWidth = split ? this.#splitWidths(width).leftWidth : width;
|
|
894
|
+
for (let index = 0; index < rowItems.length; index++) {
|
|
895
|
+
lineStartByRow.push(allLines.length);
|
|
896
|
+
const rowItem = rowItems[index];
|
|
897
|
+
if (!rowItem) continue;
|
|
898
|
+
allLines.push(
|
|
899
|
+
...renderRowLabel(
|
|
900
|
+
this.#theme,
|
|
901
|
+
rowItem,
|
|
902
|
+
question,
|
|
903
|
+
state,
|
|
904
|
+
index === state.cursorIndex,
|
|
905
|
+
this.#previewCache,
|
|
906
|
+
leftWidth,
|
|
907
|
+
!split,
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
const cursorStart = lineStartByRow[state.cursorIndex] ?? 0;
|
|
912
|
+
const cursorEnd = lineStartByRow[state.cursorIndex + 1] ?? allLines.length;
|
|
913
|
+
this.#questionCanPage = cursorEnd - cursorStart > rows;
|
|
914
|
+
state.scrollOffset = this.#scrollOffsetForCursor(
|
|
915
|
+
state.scrollOffset,
|
|
916
|
+
cursorStart,
|
|
917
|
+
cursorEnd,
|
|
918
|
+
rows,
|
|
919
|
+
allLines.length,
|
|
920
|
+
state.manualScroll,
|
|
921
|
+
);
|
|
922
|
+
const lines = windowLines(allLines, state.scrollOffset, rows);
|
|
923
|
+
if (!split) {
|
|
924
|
+
return {
|
|
925
|
+
lines,
|
|
926
|
+
scrollOffset: state.scrollOffset,
|
|
927
|
+
indicator: clipIndicator(state.scrollOffset, rows, allLines.length),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
// Side-by-side: options on the left, the cursored option's preview on
|
|
931
|
+
// the right, sharing the body window and the cursor-following scroll.
|
|
932
|
+
const theme = this.#theme;
|
|
933
|
+
const { leftWidth: lw, rightWidth } = this.#splitWidths(width);
|
|
934
|
+
const divider = theme.fg("borderMuted", " │ ");
|
|
935
|
+
const rightLines = this.#previewLinesForRow(question, state, rowItems[state.cursorIndex], rightWidth);
|
|
936
|
+
const rightOffset = Math.min(state.scrollOffset, Math.max(0, rightLines.length - rows));
|
|
937
|
+
const rightWindow = windowLines(rightLines, rightOffset, rows);
|
|
938
|
+
const merged = lines.map((line, index) => `${fit(line, lw)}${divider}${rightWindow[index] ?? ""}`);
|
|
939
|
+
return {
|
|
940
|
+
lines: merged,
|
|
941
|
+
scrollOffset: state.scrollOffset,
|
|
942
|
+
indicator: clipIndicator(state.scrollOffset, rows, allLines.length),
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Split layout: wide terminal + at least one option carries a preview. */
|
|
947
|
+
#useSplitLayout(question: ExtensionAskDialogQuestion, width: number): boolean {
|
|
948
|
+
return width >= SPLIT_MIN_WIDTH && question.options.some(option => option.preview?.trim());
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
#splitWidths(width: number): { leftWidth: number; rightWidth: number } {
|
|
952
|
+
const leftWidth = Math.max(24, Math.floor(width * 0.42));
|
|
953
|
+
return { leftWidth, rightWidth: Math.max(1, width - leftWidth - 3) };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Raw preview lines for the right pane (cursored option, or the custom
|
|
957
|
+
* answer preview under Other). */
|
|
958
|
+
#previewLinesForRow(
|
|
959
|
+
question: ExtensionAskDialogQuestion,
|
|
960
|
+
state: QuestionState,
|
|
961
|
+
rowItem: QuestionRow | undefined,
|
|
962
|
+
width: number,
|
|
963
|
+
): string[] {
|
|
964
|
+
if (!rowItem) return [];
|
|
965
|
+
if (rowItem.kind === "option") {
|
|
966
|
+
const option = question.options[rowItem.optionIndex ?? -1];
|
|
967
|
+
if (option?.preview?.trim()) {
|
|
968
|
+
return [...renderCachedPreview(this.#theme, this.#previewCache, option.preview, width)];
|
|
969
|
+
}
|
|
970
|
+
return [];
|
|
971
|
+
}
|
|
972
|
+
if (state.customInput !== undefined) {
|
|
973
|
+
const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
|
|
974
|
+
return [this.#theme.fg("muted", truncateToWidth(preview, Math.max(1, width), ELLIPSIS))];
|
|
975
|
+
}
|
|
976
|
+
return [];
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
#renderSubmitBody(width: number, rows: number): RenderedList {
|
|
980
|
+
const theme = this.#theme;
|
|
981
|
+
const allLines: string[] = [];
|
|
982
|
+
const unanswered = this.#unansweredCount();
|
|
983
|
+
if (unanswered > 0) {
|
|
984
|
+
allLines.push(
|
|
985
|
+
theme.fg(
|
|
986
|
+
"warning",
|
|
987
|
+
`${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; Enter still submits.`,
|
|
988
|
+
),
|
|
989
|
+
);
|
|
990
|
+
allLines.push("");
|
|
991
|
+
}
|
|
992
|
+
for (let index = 0; index < this.#questions.length; index++) {
|
|
993
|
+
const question = this.#questions[index];
|
|
994
|
+
const state = this.#states[index];
|
|
995
|
+
if (!question || !state) continue;
|
|
996
|
+
const label = questionTabLabel(question, index);
|
|
997
|
+
const answer = renderAnswerSummary(theme, question, state);
|
|
998
|
+
allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`);
|
|
999
|
+
}
|
|
1000
|
+
allLines.push("");
|
|
1001
|
+
allLines.push(theme.fg("accent", `${SYMBOLS.nav.cursor} ${SUBMIT_OPTION}`));
|
|
1002
|
+
this.#submitScrollOffset = clamp(this.#submitScrollOffset, 0, Math.max(0, allLines.length - rows));
|
|
1003
|
+
const lines = windowLines(allLines, this.#submitScrollOffset, rows);
|
|
1004
|
+
return {
|
|
1005
|
+
lines,
|
|
1006
|
+
scrollOffset: this.#submitScrollOffset,
|
|
1007
|
+
indicator: clipIndicator(this.#submitScrollOffset, rows, allLines.length),
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
#scrollOffsetForCursor(
|
|
1012
|
+
currentOffset: number,
|
|
1013
|
+
cursorStart: number,
|
|
1014
|
+
cursorEnd: number,
|
|
1015
|
+
rows: number,
|
|
1016
|
+
totalRows: number,
|
|
1017
|
+
manualScroll: boolean,
|
|
1018
|
+
): number {
|
|
1019
|
+
const maxOffset = Math.max(0, totalRows - rows);
|
|
1020
|
+
if (maxOffset === 0) return 0;
|
|
1021
|
+
let nextOffset = clamp(currentOffset, 0, maxOffset);
|
|
1022
|
+
const cursorRows = cursorEnd - cursorStart;
|
|
1023
|
+
if (manualScroll && cursorRows > rows) {
|
|
1024
|
+
// A page must not expose another option while Enter still targets this one.
|
|
1025
|
+
nextOffset = clamp(nextOffset, cursorStart, cursorEnd - rows);
|
|
1026
|
+
} else if (cursorStart < nextOffset || cursorEnd > nextOffset + rows) {
|
|
1027
|
+
nextOffset = cursorRows <= rows ? cursorEnd - rows : cursorStart;
|
|
1028
|
+
}
|
|
1029
|
+
return clamp(nextOffset, 0, maxOffset);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
#unansweredCount(): number {
|
|
1033
|
+
let count = 0;
|
|
1034
|
+
for (let index = 0; index < this.#questions.length; index++) {
|
|
1035
|
+
const question = this.#questions[index];
|
|
1036
|
+
const state = this.#states[index];
|
|
1037
|
+
if (!question || !state) continue;
|
|
1038
|
+
if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
|
|
1039
|
+
}
|
|
1040
|
+
return count;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
#handleTimeout(): void {
|
|
1044
|
+
if (this.#closed) return;
|
|
1045
|
+
if (this.#promptActive) {
|
|
1046
|
+
this.#timeoutExpired = true;
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
this.#options.onTimeout?.();
|
|
1050
|
+
for (let index = 0; index < this.#questions.length; index++) {
|
|
1051
|
+
const question = this.#questions[index];
|
|
1052
|
+
const state = this.#states[index];
|
|
1053
|
+
if (!question || !state) continue;
|
|
1054
|
+
if (state.selectedOptions.size === 0 && state.customInput === undefined) {
|
|
1055
|
+
const fallbackIndex = clamp(question.recommended ?? 0, 0, Math.max(0, question.options.length - 1));
|
|
1056
|
+
const fallback = question.options[fallbackIndex];
|
|
1057
|
+
if (fallback) state.selectedOptions.add(fallback.label);
|
|
1058
|
+
state.timedOut = true;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
this.#finishSubmit();
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
#runDeferredTimeout(): void {
|
|
1065
|
+
if (!this.#timeoutExpired) return;
|
|
1066
|
+
this.#timeoutExpired = false;
|
|
1067
|
+
this.#handleTimeout();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
#finishSubmit(): void {
|
|
1071
|
+
if (this.#closed) return;
|
|
1072
|
+
this.#closed = true;
|
|
1073
|
+
this.#countdown?.dispose();
|
|
1074
|
+
this.#callbacks.onSubmit({ kind: "submit", results: this.#buildResults() });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
#finishCancel(): void {
|
|
1078
|
+
if (this.#closed) return;
|
|
1079
|
+
this.#closed = true;
|
|
1080
|
+
this.#countdown?.dispose();
|
|
1081
|
+
this.#callbacks.onCancel();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Cancel from outside the component (agent turn aborted). Mirrors omp's
|
|
1086
|
+
* #presentDialog(signal) disposal path: pi's ui.custom() has no signal
|
|
1087
|
+
* option, so index.ts routes the agent abort signal here to close the
|
|
1088
|
+
* dialog and settle the custom() promise with undefined.
|
|
1089
|
+
*/
|
|
1090
|
+
externalCancel(): void {
|
|
1091
|
+
this.#finishCancel();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
#buildResults(): ExtensionAskDialogResultItem[] {
|
|
1095
|
+
const results: ExtensionAskDialogResultItem[] = [];
|
|
1096
|
+
for (let index = 0; index < this.#questions.length; index++) {
|
|
1097
|
+
const question = this.#questions[index];
|
|
1098
|
+
const state = this.#states[index];
|
|
1099
|
+
if (!question || !state) continue;
|
|
1100
|
+
const selectedOptions = question.options
|
|
1101
|
+
.map(option => option.label)
|
|
1102
|
+
.filter(label => state.selectedOptions.has(label));
|
|
1103
|
+
results.push({
|
|
1104
|
+
id: question.id,
|
|
1105
|
+
question: question.question,
|
|
1106
|
+
options: question.options.map(option => option.label),
|
|
1107
|
+
multi: question.multi ?? false,
|
|
1108
|
+
selectedOptions,
|
|
1109
|
+
customInput: state.customInput,
|
|
1110
|
+
timedOut: state.timedOut || undefined,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
return results;
|
|
1114
|
+
}
|
|
1115
|
+
}
|