@black942026/pi-ask-user 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +389 -2
- package/index.ts +2 -0
- package/package.json +62 -4
- package/src/adapters/pi.ts +239 -0
- package/src/answers.ts +84 -0
- package/src/ask.ts +103 -0
- package/src/config.ts +392 -0
- package/src/core.ts +322 -0
- package/src/deadline.ts +94 -0
- package/src/events.ts +96 -0
- package/src/index.ts +358 -0
- package/src/mode.ts +18 -0
- package/src/parse.ts +119 -0
- package/src/route.ts +144 -0
- package/src/schema.ts +255 -0
- package/src/types.ts +345 -0
- package/src/ui/custom.ts +908 -0
- package/src/ui/native.ts +112 -0
package/src/ui/custom.ts
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Input,
|
|
3
|
+
Key,
|
|
4
|
+
Markdown,
|
|
5
|
+
isKeyRelease,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import type { Component, Focusable, MarkdownTheme } from "@earendil-works/pi-tui";
|
|
12
|
+
import { draftFromDefault, draftIsEmpty, emptyDraft, finalizeAnswers, type DraftAnswer } from "../answers.ts";
|
|
13
|
+
import type { AskUserAnswer, Deadline, NormalizedQuestion, NormalizedRequest } from "../types.ts";
|
|
14
|
+
import { CANCEL_TIMEOUT_KEY } from "../config.ts";
|
|
15
|
+
|
|
16
|
+
/** Structural theme surface the component needs (a real Pi Theme satisfies it). */
|
|
17
|
+
export interface AskUserTheme {
|
|
18
|
+
fg(color: string, text: string): string;
|
|
19
|
+
bold(text: string): string;
|
|
20
|
+
italic?(text: string): string;
|
|
21
|
+
underline?(text: string): string;
|
|
22
|
+
strikethrough?(text: string): string;
|
|
23
|
+
inverse?(text: string): string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AskUserTUI {
|
|
27
|
+
requestRender(): void;
|
|
28
|
+
terminal?: { rows?: number; columns?: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CustomUIResult {
|
|
32
|
+
kind: "submitted" | "cancelled" | "timeout" | "abort" | "error";
|
|
33
|
+
answers?: AskUserAnswer[];
|
|
34
|
+
message?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AskUserComponentOptions {
|
|
38
|
+
request: NormalizedRequest;
|
|
39
|
+
deadline: Deadline;
|
|
40
|
+
/** Disable the core deadline as well as this component's local timer. */
|
|
41
|
+
cancelTimeout?: () => boolean;
|
|
42
|
+
theme: AskUserTheme;
|
|
43
|
+
tui: AskUserTUI;
|
|
44
|
+
done: (result: CustomUIResult) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Overlay show/hide key spec (for example `alt+o`), when the custom overlay
|
|
47
|
+
* registered one. Shown in the key hints so the shortcut is discoverable.
|
|
48
|
+
* `undefined` means the toggle is disabled or the route is inline.
|
|
49
|
+
*/
|
|
50
|
+
overlayToggleKey?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Terminal/pane width at or above which the preview gets its own column. */
|
|
54
|
+
export const SPLIT_MIN_WIDTH = 90;
|
|
55
|
+
/**
|
|
56
|
+
* Rows needed for the full fixed chrome — title + free-input row + key hints.
|
|
57
|
+
* When the terminal is shorter, rows are dropped in this order: title, then
|
|
58
|
+
* hints, always keeping the free-input row while at least one row remains. With
|
|
59
|
+
* zero rows nothing is rendered. Below 3 rows the question title or the key
|
|
60
|
+
* hints may be missing, but `Esc` always cancels and the input stays operable.
|
|
61
|
+
*/
|
|
62
|
+
export const MIN_USABLE_ROWS = 3;
|
|
63
|
+
const COLUMN_GAP = 2;
|
|
64
|
+
const MIN_LEFT = 30;
|
|
65
|
+
const MIN_PREVIEW = 24;
|
|
66
|
+
const MAX_BODY_RATIO = 0.85;
|
|
67
|
+
const DEFAULT_ROWS = 24;
|
|
68
|
+
const TAB_LABEL_WIDTH = 14;
|
|
69
|
+
const DESCRIPTION_INDENT = " ";
|
|
70
|
+
const MAX_TITLE_LINES = 2;
|
|
71
|
+
const MAX_PROMPT_LINES = 3;
|
|
72
|
+
const MAX_NOTICE_LINES = 2;
|
|
73
|
+
/** Keep short overlays prominent without consuming the whole terminal. */
|
|
74
|
+
export const MIN_OVERLAY_ROWS = 14;
|
|
75
|
+
const OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
76
|
+
const OVERLAY_MARGIN = 1;
|
|
77
|
+
|
|
78
|
+
function buildMarkdownTheme(theme: AskUserTheme): MarkdownTheme {
|
|
79
|
+
const color = (name: string) => (text: string) => theme.fg(name, text);
|
|
80
|
+
return {
|
|
81
|
+
heading: (text) => theme.bold(theme.fg("mdHeading", text)),
|
|
82
|
+
link: color("mdLink"),
|
|
83
|
+
linkUrl: color("mdLinkUrl"),
|
|
84
|
+
code: color("mdCode"),
|
|
85
|
+
codeBlock: color("mdCodeBlock"),
|
|
86
|
+
codeBlockBorder: color("mdCodeBlockBorder"),
|
|
87
|
+
quote: color("mdQuote"),
|
|
88
|
+
quoteBorder: color("mdQuoteBorder"),
|
|
89
|
+
hr: color("mdHr"),
|
|
90
|
+
listBullet: color("mdListBullet"),
|
|
91
|
+
bold: (text) => theme.bold(text),
|
|
92
|
+
italic: (text) => (theme.italic ? theme.italic(text) : text),
|
|
93
|
+
strikethrough: (text) => (theme.strikethrough ? theme.strikethrough(text) : text),
|
|
94
|
+
underline: (text) => (theme.underline ? theme.underline(text) : text),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function placeholderFor(question: NormalizedQuestion): string {
|
|
99
|
+
if (question.kind === "input") return "Type your answer";
|
|
100
|
+
if (question.hasDefault) return "Optional: add a note, or press Enter to use the default";
|
|
101
|
+
return "Optional: add a note";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clamp(value: number, min: number, max: number): number {
|
|
105
|
+
if (max < min) return min;
|
|
106
|
+
return Math.max(min, Math.min(max, value));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Return printable text for key input, or `undefined` for control keys and
|
|
111
|
+
* escape sequences. Multi-character paste payloads count as printable.
|
|
112
|
+
*/
|
|
113
|
+
function printableText(data: string): string | undefined {
|
|
114
|
+
if (data === "" || data.startsWith("\x1b")) return undefined;
|
|
115
|
+
for (const char of data) {
|
|
116
|
+
const code = char.codePointAt(0) ?? 0;
|
|
117
|
+
if (code < 0x20 || code === 0x7f) return undefined;
|
|
118
|
+
}
|
|
119
|
+
return data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Custom terminal UI for AskUser.
|
|
124
|
+
*
|
|
125
|
+
* Layout: a fixed title/tabs/prompt/notice header, a scrollable body (options,
|
|
126
|
+
* and the preview either in a right column or stacked below), a **pinned free
|
|
127
|
+
* input row**, and a fixed key-hint row. The input row is never scrolled out of
|
|
128
|
+
* view, so no matter how long the option descriptions or preview are the user can
|
|
129
|
+
* always see and reach the free-input entry point.
|
|
130
|
+
*
|
|
131
|
+
* The whole questionnaire shares one deadline; no route degrades on cancel,
|
|
132
|
+
* timeout, or failure. `render(width)` never exceeds the requested width, and
|
|
133
|
+
* the total height never exceeds `terminal.rows` (see {@link MIN_USABLE_ROWS} for
|
|
134
|
+
* the documented degradation below the minimum).
|
|
135
|
+
*/
|
|
136
|
+
export class AskUserComponent implements Component, Focusable {
|
|
137
|
+
private readonly request: NormalizedRequest;
|
|
138
|
+
private readonly deadline: Deadline;
|
|
139
|
+
private readonly cancelTimeout: (() => boolean) | undefined;
|
|
140
|
+
private timeoutCancelled = false;
|
|
141
|
+
private readonly theme: AskUserTheme;
|
|
142
|
+
private readonly tui: AskUserTUI;
|
|
143
|
+
private readonly done: (result: CustomUIResult) => void;
|
|
144
|
+
private readonly drafts: DraftAnswer[];
|
|
145
|
+
private readonly cursors: number[];
|
|
146
|
+
private readonly inputs: Input[];
|
|
147
|
+
private readonly mdTheme: MarkdownTheme;
|
|
148
|
+
private readonly overlayToggleKey: string | undefined;
|
|
149
|
+
private readonly previewCache = new Map<string, Markdown>();
|
|
150
|
+
|
|
151
|
+
private tab = 0;
|
|
152
|
+
private reviewButtonFocused = false;
|
|
153
|
+
private notice: string | undefined;
|
|
154
|
+
private finished = false;
|
|
155
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
156
|
+
private countdownTick: ReturnType<typeof setInterval> | undefined;
|
|
157
|
+
private remainingSeconds = 0;
|
|
158
|
+
private cachedWidth: number | undefined;
|
|
159
|
+
private cachedRows: number | undefined;
|
|
160
|
+
private cachedLines: string[] | undefined;
|
|
161
|
+
private previewScroll = 0;
|
|
162
|
+
private manualScroll = 0;
|
|
163
|
+
private viewport: { wide: boolean; maxBody: number; maxScroll: number } = {
|
|
164
|
+
wide: false,
|
|
165
|
+
maxBody: 1,
|
|
166
|
+
maxScroll: 0,
|
|
167
|
+
};
|
|
168
|
+
private _focused = false;
|
|
169
|
+
|
|
170
|
+
constructor(options: AskUserComponentOptions) {
|
|
171
|
+
this.request = options.request;
|
|
172
|
+
this.deadline = options.deadline;
|
|
173
|
+
this.cancelTimeout = options.cancelTimeout;
|
|
174
|
+
this.theme = options.theme;
|
|
175
|
+
this.tui = options.tui;
|
|
176
|
+
this.done = options.done;
|
|
177
|
+
this.mdTheme = buildMarkdownTheme(options.theme);
|
|
178
|
+
this.overlayToggleKey =
|
|
179
|
+
options.request.displayMode === "overlay" ? options.overlayToggleKey : undefined;
|
|
180
|
+
this.drafts = options.request.questions.map(() => emptyDraft());
|
|
181
|
+
this.cursors = options.request.questions.map(() => 0);
|
|
182
|
+
this.inputs = options.request.questions.map(
|
|
183
|
+
(question) => new Input({
|
|
184
|
+
prompt: "› ",
|
|
185
|
+
placeholder: placeholderFor(question),
|
|
186
|
+
placeholderStyle: (text) => this.theme.fg("muted", text),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const remaining = Math.max(0, this.deadline.remainingMs());
|
|
190
|
+
this.remainingSeconds = Math.ceil(remaining / 1000);
|
|
191
|
+
this.timer = setTimeout(() => this.finish({ kind: "timeout" }), remaining);
|
|
192
|
+
this.countdownTick = setInterval(() => {
|
|
193
|
+
const seconds = Math.ceil(Math.max(0, this.deadline.remainingMs()) / 1000);
|
|
194
|
+
if (seconds !== this.remainingSeconds) {
|
|
195
|
+
this.remainingSeconds = seconds;
|
|
196
|
+
this.refresh();
|
|
197
|
+
}
|
|
198
|
+
}, 250);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Set by the TUI when focus changes. Emitting the cursor marker depends on it,
|
|
203
|
+
* so a change invalidates cached lines.
|
|
204
|
+
*/
|
|
205
|
+
get focused(): boolean {
|
|
206
|
+
return this._focused;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
set focused(value: boolean) {
|
|
210
|
+
if (this._focused === value) return;
|
|
211
|
+
this._focused = value;
|
|
212
|
+
this.cachedWidth = undefined;
|
|
213
|
+
this.cachedRows = undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// -- lifecycle -----------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
abort(): void {
|
|
219
|
+
this.finish({ kind: "abort" });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
dispose(): void {
|
|
223
|
+
if (this.timer) {
|
|
224
|
+
clearTimeout(this.timer);
|
|
225
|
+
this.timer = undefined;
|
|
226
|
+
}
|
|
227
|
+
if (this.countdownTick) {
|
|
228
|
+
clearInterval(this.countdownTick);
|
|
229
|
+
this.countdownTick = undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private finish(result: CustomUIResult): void {
|
|
234
|
+
if (this.finished) return;
|
|
235
|
+
this.finished = true;
|
|
236
|
+
this.dispose();
|
|
237
|
+
this.done(result);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
invalidate(): void {
|
|
241
|
+
this.cachedWidth = undefined;
|
|
242
|
+
this.cachedRows = undefined;
|
|
243
|
+
for (const input of this.inputs) input.invalidate();
|
|
244
|
+
for (const markdown of this.previewCache.values()) markdown.invalidate();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private refresh(): void {
|
|
248
|
+
this.invalidate();
|
|
249
|
+
this.tui.requestRender();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private resetScroll(): void {
|
|
253
|
+
this.previewScroll = 0;
|
|
254
|
+
this.manualScroll = 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// -- tab helpers ---------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
private get hasReview(): boolean {
|
|
260
|
+
return this.request.questions.length > 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private get tabCount(): number {
|
|
264
|
+
return this.request.questions.length + (this.hasReview ? 1 : 0);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private get isReview(): boolean {
|
|
268
|
+
return this.hasReview && this.tab === this.request.questions.length;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private currentQuestion(): NormalizedQuestion {
|
|
272
|
+
return this.request.questions[this.tab]!;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private rowCount(question: NormalizedQuestion): number {
|
|
276
|
+
return question.options.length + 1;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private rowIndex(): number {
|
|
280
|
+
if (this.isReview) return 0;
|
|
281
|
+
const question = this.currentQuestion();
|
|
282
|
+
return clamp(this.cursors[this.tab]!, 0, this.rowCount(question) - 1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private isInputRow(): boolean {
|
|
286
|
+
if (this.isReview) return false;
|
|
287
|
+
return this.rowIndex() >= this.currentQuestion().options.length;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private moveTab(delta: number): void {
|
|
291
|
+
// Preserve typed text on the tab being left.
|
|
292
|
+
this.syncAllDrafts();
|
|
293
|
+
this.tab = (this.tab + delta + this.tabCount) % this.tabCount;
|
|
294
|
+
this.reviewButtonFocused = false;
|
|
295
|
+
this.notice = undefined;
|
|
296
|
+
this.resetScroll();
|
|
297
|
+
this.refresh();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private moveRow(delta: number): void {
|
|
301
|
+
const question = this.currentQuestion();
|
|
302
|
+
this.cursors[this.tab] = clamp(this.rowIndex() + delta, 0, this.rowCount(question) - 1);
|
|
303
|
+
this.resetScroll();
|
|
304
|
+
this.refresh();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private moveRowToInput(): void {
|
|
308
|
+
this.cursors[this.tab] = this.currentQuestion().options.length;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -- draft synchronisation ----------------------------------------------
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Copy the current tab's input value into its draft.
|
|
315
|
+
*
|
|
316
|
+
* The `Input` component owns the live text; drafts are the canonical answer.
|
|
317
|
+
* Every path that reads or submits a draft must sync first, otherwise text
|
|
318
|
+
* typed and then left via an option selection, a tab switch, or a review
|
|
319
|
+
* submit would be lost.
|
|
320
|
+
*/
|
|
321
|
+
private syncDraftFromInput(index: number): void {
|
|
322
|
+
const draft = this.drafts[index];
|
|
323
|
+
const input = this.inputs[index];
|
|
324
|
+
if (!draft || !input) return;
|
|
325
|
+
draft.freeText = input.getValue();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private syncAllDrafts(): void {
|
|
329
|
+
for (let index = 0; index < this.inputs.length; index += 1) this.syncDraftFromInput(index);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Forward a key to the input and drop `usedDefault` if the text changed. */
|
|
333
|
+
private forwardToInput(data: string): void {
|
|
334
|
+
const input = this.inputs[this.tab]!;
|
|
335
|
+
const before = input.getValue();
|
|
336
|
+
input.handleInput(data);
|
|
337
|
+
if (input.getValue() !== before) this.drafts[this.tab]!.usedDefault = false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// -- scrolling -----------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
private scrollBy(direction: -1 | 1): void {
|
|
343
|
+
if (this.viewport.maxScroll <= 0) return;
|
|
344
|
+
const page = Math.max(1, this.viewport.maxBody - 1);
|
|
345
|
+
if (this.viewport.wide) {
|
|
346
|
+
this.previewScroll = clamp(this.previewScroll + direction * page, 0, this.viewport.maxScroll);
|
|
347
|
+
} else {
|
|
348
|
+
this.manualScroll = clamp(this.manualScroll + direction * page, 0, this.viewport.maxScroll);
|
|
349
|
+
}
|
|
350
|
+
this.refresh();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private isScrollKey(data: string): boolean {
|
|
354
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
355
|
+
this.scrollBy(-1);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
359
|
+
this.scrollBy(1);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
if (this.isInputRow()) return false;
|
|
363
|
+
if (matchesKey(data, "ctrl+u") || data === "[") {
|
|
364
|
+
this.scrollBy(-1);
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
if (matchesKey(data, "ctrl+d") || data === "]") {
|
|
368
|
+
this.scrollBy(1);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// -- key handling --------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
handleInput(data: string): void {
|
|
377
|
+
if (this.finished) return;
|
|
378
|
+
|
|
379
|
+
if (matchesKey(data, CANCEL_TIMEOUT_KEY)) {
|
|
380
|
+
if (isKeyRelease(data) || this.timeoutCancelled || this.deadline.expired()) return;
|
|
381
|
+
if (this.cancelTimeout && !this.cancelTimeout()) return;
|
|
382
|
+
this.timeoutCancelled = true;
|
|
383
|
+
this.dispose();
|
|
384
|
+
this.refresh();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (matchesKey(data, Key.escape)) {
|
|
388
|
+
this.finish({ kind: "cancelled" });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (matchesKey(data, "ctrl+enter")) {
|
|
392
|
+
this.submitAll();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (matchesKey(data, "shift+tab")) {
|
|
396
|
+
this.moveTab(-1);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (matchesKey(data, Key.tab)) {
|
|
400
|
+
this.moveTab(1);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (this.isScrollKey(data)) return;
|
|
404
|
+
|
|
405
|
+
if (this.isReview) {
|
|
406
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "ctrl+n")) {
|
|
407
|
+
this.reviewButtonFocused = true;
|
|
408
|
+
this.refresh();
|
|
409
|
+
} else if (matchesKey(data, Key.up) || matchesKey(data, "ctrl+p")) {
|
|
410
|
+
this.reviewButtonFocused = false;
|
|
411
|
+
this.refresh();
|
|
412
|
+
} else if (matchesKey(data, Key.enter) || (this.reviewButtonFocused && matchesKey(data, Key.space))) {
|
|
413
|
+
this.submitAll();
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const question = this.currentQuestion();
|
|
419
|
+
if (question.kind === "input") {
|
|
420
|
+
this.cursors[this.tab] = question.options.length;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.isInputRow()) {
|
|
424
|
+
// Up returns to the options instead of being swallowed by the input.
|
|
425
|
+
if (question.options.length > 0 && (matchesKey(data, Key.up) || matchesKey(data, "ctrl+p"))) {
|
|
426
|
+
this.moveRow(-1);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (matchesKey(data, "shift+enter")) {
|
|
430
|
+
this.forwardToInput(data);
|
|
431
|
+
this.refresh();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (matchesKey(data, Key.enter)) {
|
|
435
|
+
this.commitAndAdvance();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.forwardToInput(data);
|
|
439
|
+
this.refresh();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "ctrl+p")) {
|
|
444
|
+
this.moveRow(-1);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "ctrl+n")) {
|
|
448
|
+
this.moveRow(1);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (matchesKey(data, Key.space)) {
|
|
452
|
+
if (question.kind === "multi") {
|
|
453
|
+
this.toggleRow();
|
|
454
|
+
} else {
|
|
455
|
+
this.syncDraftFromInput(this.tab);
|
|
456
|
+
this.drafts[this.tab]!.selections = [this.rowIndex()];
|
|
457
|
+
this.drafts[this.tab]!.usedDefault = false;
|
|
458
|
+
this.notice = undefined;
|
|
459
|
+
this.resetScroll();
|
|
460
|
+
this.refresh();
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (matchesKey(data, Key.enter)) {
|
|
465
|
+
this.onOptionEnter();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (matchesKey(data, "s")) {
|
|
469
|
+
this.skipWithDefault();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const printable = printableText(data);
|
|
474
|
+
if (printable !== undefined) {
|
|
475
|
+
this.moveRowToInput();
|
|
476
|
+
this.forwardToInput(data);
|
|
477
|
+
this.refresh();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private onOptionEnter(): void {
|
|
482
|
+
const question = this.currentQuestion();
|
|
483
|
+
if (question.kind === "multi") {
|
|
484
|
+
this.toggleRow();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// Sync any typed note before advancing, so selecting an option never drops
|
|
488
|
+
// free text that was entered from the pinned input row.
|
|
489
|
+
this.syncDraftFromInput(this.tab);
|
|
490
|
+
// Single-select: Enter picks and advances. A note can be added first by
|
|
491
|
+
// moving down to the free-input row.
|
|
492
|
+
const row = this.rowIndex();
|
|
493
|
+
this.drafts[this.tab]!.selections = [row];
|
|
494
|
+
this.drafts[this.tab]!.usedDefault = false;
|
|
495
|
+
this.notice = undefined;
|
|
496
|
+
this.resetScroll();
|
|
497
|
+
this.advance();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private toggleRow(): void {
|
|
501
|
+
this.syncDraftFromInput(this.tab);
|
|
502
|
+
const row = this.rowIndex();
|
|
503
|
+
const draft = this.drafts[this.tab]!;
|
|
504
|
+
if (draft.selections.includes(row)) {
|
|
505
|
+
draft.selections = draft.selections.filter((index) => index !== row);
|
|
506
|
+
} else {
|
|
507
|
+
draft.selections = [...draft.selections, row].sort((a, b) => a - b);
|
|
508
|
+
}
|
|
509
|
+
draft.usedDefault = false;
|
|
510
|
+
this.notice = undefined;
|
|
511
|
+
this.resetScroll();
|
|
512
|
+
this.refresh();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private skipWithDefault(): void {
|
|
516
|
+
const question = this.currentQuestion();
|
|
517
|
+
const fallback = draftFromDefault(question);
|
|
518
|
+
if (!fallback) {
|
|
519
|
+
this.notice = "This question has no default and is required.";
|
|
520
|
+
this.refresh();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
this.drafts[this.tab] = fallback;
|
|
524
|
+
this.inputs[this.tab]!.setValue(fallback.freeText);
|
|
525
|
+
this.notice = undefined;
|
|
526
|
+
this.resetScroll();
|
|
527
|
+
this.advance();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private commitAndAdvance(): void {
|
|
531
|
+
const question = this.currentQuestion();
|
|
532
|
+
this.syncDraftFromInput(this.tab);
|
|
533
|
+
const draft = this.drafts[this.tab]!;
|
|
534
|
+
if (draftIsEmpty(draft) && !question.hasDefault) {
|
|
535
|
+
this.notice = "This question is required: select an option or type text.";
|
|
536
|
+
this.refresh();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
this.notice = undefined;
|
|
540
|
+
this.resetScroll();
|
|
541
|
+
this.advance();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private advance(): void {
|
|
545
|
+
// Any transition may lead to a review or submit, so fold every tab's live
|
|
546
|
+
// input text into its draft first.
|
|
547
|
+
this.syncAllDrafts();
|
|
548
|
+
if (this.request.questions.length === 1) {
|
|
549
|
+
this.submitAll();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (this.tab < this.request.questions.length - 1) {
|
|
553
|
+
this.tab += 1;
|
|
554
|
+
this.notice = undefined;
|
|
555
|
+
this.resetScroll();
|
|
556
|
+
this.refresh();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
this.tab = this.request.questions.length;
|
|
560
|
+
this.reviewButtonFocused = false;
|
|
561
|
+
this.notice = undefined;
|
|
562
|
+
this.resetScroll();
|
|
563
|
+
this.refresh();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private submitAll(): void {
|
|
567
|
+
// Final safety net: never submit from stale drafts.
|
|
568
|
+
this.syncAllDrafts();
|
|
569
|
+
const { answers, unanswered } = finalizeAnswers(this.request.questions, this.drafts);
|
|
570
|
+
if (unanswered.length > 0) {
|
|
571
|
+
const first = unanswered[0]!;
|
|
572
|
+
this.tab = first;
|
|
573
|
+
this.reviewButtonFocused = false;
|
|
574
|
+
this.cursors[first] = 0;
|
|
575
|
+
this.notice = `Question ${first + 1} has no answer yet; answer it first.`;
|
|
576
|
+
this.resetScroll();
|
|
577
|
+
this.refresh();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this.finish({ kind: "submitted", answers });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// -- rendering helpers ---------------------------------------------------
|
|
584
|
+
|
|
585
|
+
private padTo(line: string, width: number): string {
|
|
586
|
+
const visible = visibleWidth(line);
|
|
587
|
+
if (visible >= width) return line;
|
|
588
|
+
return line + " ".repeat(width - visible);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private countdownLabel(): string {
|
|
592
|
+
if (this.timeoutCancelled) return "No timeout";
|
|
593
|
+
const hours = Math.floor(this.remainingSeconds / 3600);
|
|
594
|
+
const minutes = Math.floor((this.remainingSeconds % 3600) / 60);
|
|
595
|
+
const seconds = String(this.remainingSeconds % 60).padStart(2, "0");
|
|
596
|
+
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Match the reference dialog's bordered card without affecting input or answers. */
|
|
600
|
+
private frameLines(lines: string[], width: number): string[] {
|
|
601
|
+
const innerWidth = width - 4; // │ + space on each side
|
|
602
|
+
const countdown = truncateToWidth(this.countdownLabel(), Math.max(1, width - 8));
|
|
603
|
+
const label = this.request.header?.trim() || "ask_user";
|
|
604
|
+
const heading = `╭─ ${truncateToWidth(label, Math.max(1, width - visibleWidth(countdown) - 7))} ${countdown} `;
|
|
605
|
+
const top = heading + "─".repeat(width - visibleWidth(heading) - 1) + "╮";
|
|
606
|
+
return [
|
|
607
|
+
this.theme.fg("accent", top),
|
|
608
|
+
...lines.map((line) =>
|
|
609
|
+
this.theme.fg("accent", "│ ") +
|
|
610
|
+
this.padTo(truncateToWidth(line, innerWidth), innerWidth) +
|
|
611
|
+
this.theme.fg("accent", " │"),
|
|
612
|
+
),
|
|
613
|
+
this.theme.fg("accent", "╰" + "─".repeat(width - 2) + "╯"),
|
|
614
|
+
];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private wrap(text: string, width: number): string[] {
|
|
618
|
+
return wrapTextWithAnsi(text, Math.max(1, width));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private markdownFor(text: string): Markdown {
|
|
622
|
+
let instance = this.previewCache.get(text);
|
|
623
|
+
if (!instance) {
|
|
624
|
+
instance = new Markdown(text, 0, 0, this.mdTheme);
|
|
625
|
+
this.previewCache.set(text, instance);
|
|
626
|
+
}
|
|
627
|
+
return instance;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private currentPreview(): string {
|
|
631
|
+
if (this.isReview) return "";
|
|
632
|
+
const question = this.currentQuestion();
|
|
633
|
+
const draft = this.drafts[this.tab]!;
|
|
634
|
+
const row = this.isInputRow() ? (draft.selections[0] ?? -1) : this.rowIndex();
|
|
635
|
+
const option = question.options[row];
|
|
636
|
+
return option?.preview ?? "";
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private autoOffset(total: number, focusLine: number, height: number): number {
|
|
640
|
+
if (total <= height) return 0;
|
|
641
|
+
return clamp(focusLine - Math.floor(height / 2), 0, total - height);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private renderTabs(width: number): string {
|
|
645
|
+
const inverse = this.theme.inverse ?? ((text: string) => text);
|
|
646
|
+
const chips: string[] = [];
|
|
647
|
+
this.request.questions.forEach((question, index) => {
|
|
648
|
+
const draft = this.drafts[index]!;
|
|
649
|
+
const answered = !draftIsEmpty(draft) || question.hasDefault;
|
|
650
|
+
const marker = answered ? "●" : "○";
|
|
651
|
+
const label = truncateToWidth(question.title, TAB_LABEL_WIDTH);
|
|
652
|
+
const chip = `${marker} ${index + 1}.${label}`;
|
|
653
|
+
chips.push(
|
|
654
|
+
index === this.tab
|
|
655
|
+
? inverse(this.theme.fg("accent", ` ${chip} `))
|
|
656
|
+
: this.theme.fg(answered ? "success" : "muted", ` ${chip} `),
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
if (this.hasReview) {
|
|
660
|
+
const chip = "✓ Submit";
|
|
661
|
+
chips.push(
|
|
662
|
+
this.isReview ? inverse(this.theme.fg("accent", ` ${chip} `)) : this.theme.fg("muted", ` ${chip} `),
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return truncateToWidth(chips.join(this.theme.fg("dim", "│")), width);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private renderOptionRow(question: NormalizedQuestion, index: number, width: number): string[] {
|
|
669
|
+
const draft = this.drafts[this.tab]!;
|
|
670
|
+
const row = this.rowIndex();
|
|
671
|
+
const isCursor = !this.isInputRow() && index === row;
|
|
672
|
+
const selected = draft.selections.includes(index);
|
|
673
|
+
const cursorMark = isCursor ? this.theme.fg("accent", "▸") : " ";
|
|
674
|
+
const box = question.kind === "multi" ? (selected ? "▣" : "▢") : selected ? "◉" : "○";
|
|
675
|
+
const selectedMark = selected ? this.theme.fg("success", box) : this.theme.fg("muted", box);
|
|
676
|
+
const label = question.options[index]!.label;
|
|
677
|
+
const labelText = isCursor ? this.theme.fg("accent", this.theme.bold(label)) : label;
|
|
678
|
+
const lines = [truncateToWidth(` ${cursorMark} ${selectedMark} ${index + 1}. ${labelText}`, width)];
|
|
679
|
+
const description = question.options[index]!.description;
|
|
680
|
+
if (description) {
|
|
681
|
+
// Wrap to the option column instead of hard-truncating the text.
|
|
682
|
+
for (const wrapped of this.wrap(description, Math.max(1, width - DESCRIPTION_INDENT.length))) {
|
|
683
|
+
lines.push(truncateToWidth(this.theme.fg("muted", `${DESCRIPTION_INDENT}${wrapped}`), width));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return lines;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Options only (no input row): the input stays pinned outside the body. */
|
|
690
|
+
private renderOptions(width: number): { lines: string[]; focusLine: number } {
|
|
691
|
+
const question = this.currentQuestion();
|
|
692
|
+
const lines: string[] = [];
|
|
693
|
+
let focusLine = 0;
|
|
694
|
+
for (let index = 0; index < question.options.length; index += 1) {
|
|
695
|
+
if (!this.isInputRow() && index === this.rowIndex()) focusLine = lines.length;
|
|
696
|
+
lines.push(...this.renderOptionRow(question, index, width));
|
|
697
|
+
}
|
|
698
|
+
return { lines: lines.map((line) => truncateToWidth(line, width)), focusLine };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private renderBodyArea(width: number, height: number): string[] {
|
|
702
|
+
if (height <= 0) return [];
|
|
703
|
+
if (this.isReview) {
|
|
704
|
+
const lines = this.renderReview(width);
|
|
705
|
+
const maxScroll = Math.max(0, lines.length - height);
|
|
706
|
+
this.viewport = { wide: false, maxBody: height, maxScroll };
|
|
707
|
+
this.manualScroll = clamp(this.manualScroll, 0, maxScroll);
|
|
708
|
+
return lines.slice(this.manualScroll, this.manualScroll + height);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const preview = this.currentPreview();
|
|
712
|
+
const wide = preview.trim() !== "" && width >= SPLIT_MIN_WIDTH;
|
|
713
|
+
|
|
714
|
+
if (wide) {
|
|
715
|
+
const half = Math.floor(width * 0.5);
|
|
716
|
+
const maxLeft = Math.max(1, width - COLUMN_GAP - 1);
|
|
717
|
+
const lowLeft = Math.min(MIN_LEFT, maxLeft);
|
|
718
|
+
const highLeft = Math.max(lowLeft, Math.min(maxLeft, width - COLUMN_GAP - MIN_PREVIEW));
|
|
719
|
+
const leftWidth = clamp(half, lowLeft, highLeft);
|
|
720
|
+
const rightWidth = Math.max(1, width - leftWidth - COLUMN_GAP);
|
|
721
|
+
const projection = this.renderOptions(leftWidth);
|
|
722
|
+
const right = this.markdownFor(preview).render(rightWidth);
|
|
723
|
+
const leftOffset = this.autoOffset(projection.lines.length, projection.focusLine, height);
|
|
724
|
+
const maxScroll = Math.max(0, right.length - height);
|
|
725
|
+
this.viewport = { wide: true, maxBody: height, maxScroll };
|
|
726
|
+
this.previewScroll = clamp(this.previewScroll, 0, maxScroll);
|
|
727
|
+
const gap = " ".repeat(COLUMN_GAP);
|
|
728
|
+
const totalRows = Math.max(projection.lines.length - leftOffset, right.length - this.previewScroll);
|
|
729
|
+
const visibleCount = clamp(totalRows, 1, height);
|
|
730
|
+
const merged: string[] = [];
|
|
731
|
+
for (let index = 0; index < visibleCount; index += 1) {
|
|
732
|
+
const leftLine = projection.lines[leftOffset + index] ?? "";
|
|
733
|
+
const rightLine = right[this.previewScroll + index] ?? "";
|
|
734
|
+
merged.push(
|
|
735
|
+
this.padTo(truncateToWidth(leftLine, leftWidth), leftWidth) +
|
|
736
|
+
gap +
|
|
737
|
+
truncateToWidth(rightLine, rightWidth),
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
return merged;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const projection = this.renderOptions(width);
|
|
744
|
+
const lines = projection.lines.slice();
|
|
745
|
+
if (preview.trim() !== "") {
|
|
746
|
+
lines.push("");
|
|
747
|
+
lines.push(this.theme.fg("border", "─".repeat(width)));
|
|
748
|
+
lines.push(this.theme.fg("muted", "Preview:"));
|
|
749
|
+
lines.push(...this.markdownFor(preview).render(width));
|
|
750
|
+
}
|
|
751
|
+
const maxScroll = Math.max(0, lines.length - height);
|
|
752
|
+
this.viewport = { wide: false, maxBody: height, maxScroll };
|
|
753
|
+
this.manualScroll = clamp(this.manualScroll, 0, maxScroll);
|
|
754
|
+
const offset =
|
|
755
|
+
this.manualScroll > 0 ? this.manualScroll : this.autoOffset(lines.length, projection.focusLine, height);
|
|
756
|
+
return lines.slice(offset, offset + height);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** The free-input row, rendered as a fixed line that never scrolls away. */
|
|
760
|
+
private renderInputBar(width: number): string[] {
|
|
761
|
+
const input = this.inputs[this.tab]!;
|
|
762
|
+
input.focused = this.focused && this.isInputRow();
|
|
763
|
+
return input
|
|
764
|
+
.render(Math.max(1, width - 2))
|
|
765
|
+
.slice(0, 1)
|
|
766
|
+
.map((line) => {
|
|
767
|
+
// Pi's Input draws an inverse-video fake cursor even when unfocused.
|
|
768
|
+
// Only show that highlight while this row actually owns input focus.
|
|
769
|
+
const display = input.focused ? line : line.replaceAll("\x1b[7m", "").replaceAll("\x1b[27m", "");
|
|
770
|
+
return truncateToWidth(` ${display}`, width);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private renderSubmitBar(width: number): string[] {
|
|
775
|
+
const label = this.reviewButtonFocused ? "▸ [ Enter Submit ]" : " [ Enter Submit ]";
|
|
776
|
+
const styled = this.theme.fg("accent", this.reviewButtonFocused ? (this.theme.inverse?.(label) ?? label) : label);
|
|
777
|
+
return [truncateToWidth(styled, width)];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private renderReview(width: number): string[] {
|
|
781
|
+
const lines: string[] = [];
|
|
782
|
+
this.request.questions.forEach((question, index) => {
|
|
783
|
+
const draft = this.drafts[index]!;
|
|
784
|
+
const answered = !draftIsEmpty(draft) || question.hasDefault;
|
|
785
|
+
const mark = answered ? this.theme.fg("success", "✓") : this.theme.fg("warning", "!");
|
|
786
|
+
lines.push(truncateToWidth(`${mark} ${this.theme.bold(`${index + 1}. ${question.title}`)}`, width));
|
|
787
|
+
lines.push(truncateToWidth(this.theme.fg("muted", ` ${this.describeDraft(question, draft)}`), width));
|
|
788
|
+
});
|
|
789
|
+
return lines;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private describeDraft(question: NormalizedQuestion, draft: DraftAnswer): string {
|
|
793
|
+
const parts: string[] = [];
|
|
794
|
+
if (draft.selections.length > 0) {
|
|
795
|
+
parts.push(draft.selections.map((index) => question.options[index]?.label ?? `#${index + 1}`).join(", "));
|
|
796
|
+
}
|
|
797
|
+
const freeText = draft.freeText.trim();
|
|
798
|
+
if (freeText !== "") parts.push(`"${freeText}"`);
|
|
799
|
+
if (parts.length === 0) {
|
|
800
|
+
const fallback = draftFromDefault(question);
|
|
801
|
+
if (fallback) return `Will use the default: ${question.default}`;
|
|
802
|
+
return "(unanswered)";
|
|
803
|
+
}
|
|
804
|
+
const suffix = draft.usedDefault ? " (default)" : "";
|
|
805
|
+
return parts.join(" | ") + suffix;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private hintLine(width: number): string {
|
|
809
|
+
// Keep cancellation controls discoverable even when contextual hints grow.
|
|
810
|
+
const controls = ["Esc cancel"];
|
|
811
|
+
if (!this.timeoutCancelled) controls.push(`${CANCEL_TIMEOUT_KEY} stop timer`);
|
|
812
|
+
if (this.overlayToggleKey) controls.push(`${this.overlayToggleKey} hide`);
|
|
813
|
+
const controlText = controls.join(" · ");
|
|
814
|
+
const withControls = (hints: string[]): string => {
|
|
815
|
+
const contextWidth = width - visibleWidth(controlText) - 3;
|
|
816
|
+
const context = contextWidth > 0 ? truncateToWidth(hints.join(" · "), contextWidth) : "";
|
|
817
|
+
return this.theme.fg("dim", context ? `${context} · ${controlText}` : controlText);
|
|
818
|
+
};
|
|
819
|
+
if (this.isReview) {
|
|
820
|
+
const reviewHints = ["↓ focus submit", "Enter submit", "Tab switch question"];
|
|
821
|
+
return withControls(reviewHints);
|
|
822
|
+
}
|
|
823
|
+
const question = this.currentQuestion();
|
|
824
|
+
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
|
|
825
|
+
if (question.kind === "multi") hints.push("Space multi-select");
|
|
826
|
+
if (question.hasDefault) hints.push("s use default");
|
|
827
|
+
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
|
|
828
|
+
if (this.isInputRow() && question.options.length > 0) hints.push("↑ back to options");
|
|
829
|
+
return withControls(hints);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
render(width: number): string[] {
|
|
833
|
+
const lineWidth = Math.max(1, Math.floor(width));
|
|
834
|
+
const physical = Math.max(0, Math.floor(this.tui.terminal?.rows ?? DEFAULT_ROWS));
|
|
835
|
+
if (this.cachedLines && this.cachedWidth === lineWidth && this.cachedRows === physical) return this.cachedLines;
|
|
836
|
+
if (physical === 0) {
|
|
837
|
+
this.cachedWidth = lineWidth;
|
|
838
|
+
this.cachedRows = physical;
|
|
839
|
+
this.cachedLines = [];
|
|
840
|
+
return [];
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// The host caps overlays at 85% with a one-row margin. Respect that cap
|
|
844
|
+
// ourselves so the host never chops off the pinned input or bottom border.
|
|
845
|
+
const availableRows = this.request.displayMode === "overlay" && physical >= 7
|
|
846
|
+
? Math.min(physical, Math.floor(physical * OVERLAY_MAX_HEIGHT_RATIO), Math.max(0, physical - 2 * OVERLAY_MARGIN))
|
|
847
|
+
: physical;
|
|
848
|
+
// Small viewports keep the original minimal layout so controls remain visible.
|
|
849
|
+
const framed = lineWidth >= 16 && availableRows >= 7;
|
|
850
|
+
const contentWidth = framed ? lineWidth - 4 : lineWidth;
|
|
851
|
+
const contentRows = framed ? availableRows - 2 : availableRows;
|
|
852
|
+
|
|
853
|
+
// Fixed blocks, with the free-input row and key hints guaranteed highest
|
|
854
|
+
// priority so they survive a short terminal.
|
|
855
|
+
const titleText = this.isReview
|
|
856
|
+
? "Review before submitting"
|
|
857
|
+
: `${this.currentQuestion().index + 1}/${this.request.questions.length}. ${this.currentQuestion().title}`;
|
|
858
|
+
const titleBlock = this.wrap(this.theme.bold(this.theme.fg("accent", titleText)), contentWidth).slice(
|
|
859
|
+
0,
|
|
860
|
+
MAX_TITLE_LINES,
|
|
861
|
+
);
|
|
862
|
+
const tabsBlock = this.request.questions.length > 1 ? [this.renderTabs(contentWidth)] : [];
|
|
863
|
+
const countdown = this.countdownLabel();
|
|
864
|
+
const headerBlock = !framed
|
|
865
|
+
? [contentWidth <= visibleWidth(countdown) + 1
|
|
866
|
+
? truncateToWidth(countdown, contentWidth)
|
|
867
|
+
: `${this.theme.bold(truncateToWidth(this.request.header?.trim() || "ask_user", contentWidth - visibleWidth(countdown) - 1))} ${countdown}`]
|
|
868
|
+
: [];
|
|
869
|
+
const prompt = this.isReview ? undefined : this.currentQuestion().prompt?.trim();
|
|
870
|
+
const promptBlock = prompt
|
|
871
|
+
? this.wrap(this.theme.fg("text", prompt), contentWidth).slice(0, MAX_PROMPT_LINES)
|
|
872
|
+
: [];
|
|
873
|
+
const noticeBlock = this.notice
|
|
874
|
+
? this.wrap(this.theme.fg("warning", `⚠ ${this.notice}`), contentWidth).slice(0, MAX_NOTICE_LINES)
|
|
875
|
+
: [];
|
|
876
|
+
const inputBlock = this.isReview ? this.renderSubmitBar(contentWidth) : this.renderInputBar(contentWidth);
|
|
877
|
+
const hintBlock = [this.hintLine(contentWidth)];
|
|
878
|
+
|
|
879
|
+
let remaining = contentRows;
|
|
880
|
+
const take = (block: string[]): string[] => {
|
|
881
|
+
if (remaining <= 0 || block.length === 0) return [];
|
|
882
|
+
const use = Math.min(block.length, remaining);
|
|
883
|
+
remaining -= use;
|
|
884
|
+
return block.slice(0, use);
|
|
885
|
+
};
|
|
886
|
+
const inputOut = take(inputBlock);
|
|
887
|
+
const hintOut = take(hintBlock);
|
|
888
|
+
const titleOut = take(titleBlock);
|
|
889
|
+
const tabsOut = take(tabsBlock);
|
|
890
|
+
const headerOut = take(headerBlock);
|
|
891
|
+
const noticeOut = take(noticeBlock);
|
|
892
|
+
const promptOut = take(promptBlock);
|
|
893
|
+
const bodyOut = remaining > 0 ? this.renderBodyArea(contentWidth, remaining) : [];
|
|
894
|
+
|
|
895
|
+
const leading = [...headerOut, ...tabsOut, ...titleOut, ...promptOut, ...noticeOut, ...bodyOut];
|
|
896
|
+
const minimum = this.request.displayMode === "overlay" ? Math.min(MIN_OVERLAY_ROWS, availableRows) : 0;
|
|
897
|
+
const padding = Math.max(0, minimum - (leading.length + inputOut.length + hintOut.length + (framed ? 2 : 0)));
|
|
898
|
+
// Keep input/Submit adjacent to the choices, while pinning the key hints
|
|
899
|
+
// to the bottom edge of a padded overlay.
|
|
900
|
+
const lines = [...leading, ...inputOut, ...Array<string>(padding).fill(""), ...hintOut];
|
|
901
|
+
const content = lines.slice(0, contentRows).map((line) => truncateToWidth(line, contentWidth));
|
|
902
|
+
const finalLines = framed ? this.frameLines(content, lineWidth) : content;
|
|
903
|
+
this.cachedWidth = lineWidth;
|
|
904
|
+
this.cachedRows = physical;
|
|
905
|
+
this.cachedLines = finalLines;
|
|
906
|
+
return finalLines;
|
|
907
|
+
}
|
|
908
|
+
}
|