@pi9/ask 0.1.0
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 +92 -0
- package/package.json +66 -0
- package/src/component.ts +570 -0
- package/src/config.ts +30 -0
- package/src/context.ts +209 -0
- package/src/deadline.ts +78 -0
- package/src/glyphs.ts +4 -0
- package/src/index.ts +231 -0
- package/src/preview.ts +55 -0
- package/src/questionnaire.ts +37 -0
- package/src/replay-renderer.ts +85 -0
- package/src/replay.ts +100 -0
- package/src/response.ts +31 -0
- package/src/rpc.ts +133 -0
- package/src/schema.ts +43 -0
- package/src/state.ts +203 -0
- package/src/types.ts +33 -0
- package/src/validation.ts +48 -0
- package/src/viewport.ts +148 -0
package/src/component.ts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CURSOR_MARKER,
|
|
3
|
+
Editor,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
parseKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
type Component,
|
|
11
|
+
type EditorTheme,
|
|
12
|
+
type Focusable,
|
|
13
|
+
type Keybinding,
|
|
14
|
+
type KeybindingsManager,
|
|
15
|
+
type TUI,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createQuestionnaireState,
|
|
21
|
+
transitionQuestionnaire,
|
|
22
|
+
type QuestionnaireState,
|
|
23
|
+
} from "./state.js";
|
|
24
|
+
import { CHECKED_BOX, EMPTY_BOX } from "./glyphs.js";
|
|
25
|
+
import {
|
|
26
|
+
composePreviewRow,
|
|
27
|
+
getPreviewPaneLayout,
|
|
28
|
+
renderPreviewMarkdown,
|
|
29
|
+
type PreviewPaneLayout,
|
|
30
|
+
} from "./preview.js";
|
|
31
|
+
import {
|
|
32
|
+
fitViewport,
|
|
33
|
+
type FocusRange,
|
|
34
|
+
type ViewportOverflow,
|
|
35
|
+
} from "./viewport.js";
|
|
36
|
+
import type { AskAnswer, ValidatedAskParams } from "./types.js";
|
|
37
|
+
|
|
38
|
+
type AskComponentOptions = ValidatedAskParams & {
|
|
39
|
+
tui: TUI;
|
|
40
|
+
theme: Theme;
|
|
41
|
+
keybindings: KeybindingsManager;
|
|
42
|
+
onSubmit?: (answer: AskAnswer) => void;
|
|
43
|
+
onCancel?: () => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export class AskComponent implements Component, Focusable {
|
|
47
|
+
private readonly editor: Editor;
|
|
48
|
+
private readonly keybindings: KeybindingsManager;
|
|
49
|
+
private readonly previewHeightByWidth = new Map<number, number>();
|
|
50
|
+
private questionnaireState: QuestionnaireState;
|
|
51
|
+
private cancelled = false;
|
|
52
|
+
private _focused = false;
|
|
53
|
+
|
|
54
|
+
constructor(private readonly config: AskComponentOptions) {
|
|
55
|
+
this.keybindings = config.keybindings;
|
|
56
|
+
this.questionnaireState = createQuestionnaireState({
|
|
57
|
+
options: config.options,
|
|
58
|
+
allowMultiple: config.allowMultiple,
|
|
59
|
+
allowFreeform: config.allowFreeform,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.editor = new Editor(config.tui, editorTheme(config.theme));
|
|
63
|
+
this.editor.onChange = (value) => {
|
|
64
|
+
if (this.questionnaireState.editor.kind === "select") return;
|
|
65
|
+
this.applyState(transitionQuestionnaire(this.questionnaireState, { type: "edit", value }));
|
|
66
|
+
};
|
|
67
|
+
this.editor.onSubmit = (value) => {
|
|
68
|
+
if (this.questionnaireState.editor.kind === "select") return;
|
|
69
|
+
const next = transitionQuestionnaire(
|
|
70
|
+
transitionQuestionnaire(this.questionnaireState, { type: "edit", value }),
|
|
71
|
+
{ type: "saveEditor" },
|
|
72
|
+
);
|
|
73
|
+
this.applyState(next);
|
|
74
|
+
this.finishIfAnswered(next);
|
|
75
|
+
this.requestRender();
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Current questionnaire state, useful to UI integrators. */
|
|
80
|
+
get state(): QuestionnaireState {
|
|
81
|
+
return this.questionnaireState;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The answer after a successful submit, or null while the prompt is open. */
|
|
85
|
+
get answer(): AskAnswer | null {
|
|
86
|
+
return this.questionnaireState.answer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the component was cancelled. */
|
|
90
|
+
get isCancelled(): boolean {
|
|
91
|
+
return this.cancelled;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get focused(): boolean {
|
|
95
|
+
return this._focused;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
set focused(value: boolean) {
|
|
99
|
+
this._focused = value;
|
|
100
|
+
this.editor.focused = value && this.questionnaireState.editor.kind !== "select";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
invalidate(): void {
|
|
104
|
+
this.editor.invalidate();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
handleInput(data: string): void {
|
|
108
|
+
if (this.questionnaireState.answer || this.cancelled) return;
|
|
109
|
+
|
|
110
|
+
if (this.questionnaireState.editor.kind !== "select") {
|
|
111
|
+
// Escape is intentionally an editor operation: it discards the draft,
|
|
112
|
+
// while Ctrl+C remains the conventional way to cancel the whole ask.
|
|
113
|
+
if (matchesKey(data, Key.escape)) {
|
|
114
|
+
this.applyState(transitionQuestionnaire(this.questionnaireState, { type: "cancelEditor" }));
|
|
115
|
+
this.requestRender();
|
|
116
|
+
}
|
|
117
|
+
else if (matchesKey(data, Key.ctrl("c"))) {
|
|
118
|
+
this.cancel();
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
this.editor.handleInput(data);
|
|
122
|
+
this.requestRender();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Configured Pi bindings take precedence over fixed shortcuts. The order
|
|
126
|
+
// here also defines precedence if a user assigns one key to two Pi actions.
|
|
127
|
+
else if (this.matchesSelect(data, "tui.select.cancel")) {
|
|
128
|
+
this.cancel();
|
|
129
|
+
}
|
|
130
|
+
else if (this.matchesSelect(data, "tui.select.up")) {
|
|
131
|
+
this.move(-1);
|
|
132
|
+
}
|
|
133
|
+
else if (this.matchesSelect(data, "tui.select.down")) {
|
|
134
|
+
this.move(1);
|
|
135
|
+
}
|
|
136
|
+
else if (this.matchesSelect(data, "tui.select.pageUp")) {
|
|
137
|
+
this.move(-5);
|
|
138
|
+
}
|
|
139
|
+
else if (this.matchesSelect(data, "tui.select.pageDown")) {
|
|
140
|
+
this.move(5);
|
|
141
|
+
}
|
|
142
|
+
else if (this.matchesSelect(data, "tui.select.confirm")) {
|
|
143
|
+
this.confirm();
|
|
144
|
+
}
|
|
145
|
+
else if (matchesKey(data, Key.ctrl("c"))) {
|
|
146
|
+
this.cancel();
|
|
147
|
+
}
|
|
148
|
+
else if (matchesKey(data, Key.home)) {
|
|
149
|
+
this.move(-this.questionnaireState.highlightedRow);
|
|
150
|
+
}
|
|
151
|
+
else if (matchesKey(data, Key.end)) {
|
|
152
|
+
this.move(this.questionnaireState.rows.length - 1 - this.questionnaireState.highlightedRow);
|
|
153
|
+
}
|
|
154
|
+
else if (isLiteral(data, "c")) {
|
|
155
|
+
this.applyState(transitionQuestionnaire(this.questionnaireState, { type: "openComment" }));
|
|
156
|
+
}
|
|
157
|
+
else if (matchesKey(data, Key.space)) {
|
|
158
|
+
this.dispatchCurrentRow("toggle");
|
|
159
|
+
}
|
|
160
|
+
else if (isLiteral(data, "k")) {
|
|
161
|
+
this.move(-1);
|
|
162
|
+
}
|
|
163
|
+
else if (isLiteral(data, "j")) {
|
|
164
|
+
this.move(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
render(width: number): string[] {
|
|
169
|
+
const renderWidth = safeWidth(width);
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
const add = (line: string) => lines.push(fit(line, renderWidth));
|
|
172
|
+
const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
|
|
173
|
+
const styled = color ? this.config.theme.fg(color, text) : text;
|
|
174
|
+
addWrappedWithPrefix(lines, prefix, styled, renderWidth);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
add(this.config.theme.fg("border", "─".repeat(renderWidth)));
|
|
178
|
+
|
|
179
|
+
if (this.config.context) {
|
|
180
|
+
addPrefixed(" ", this.config.context, "muted");
|
|
181
|
+
add("");
|
|
182
|
+
}
|
|
183
|
+
addPrefixed(" ", this.config.theme.bold(this.config.question), "text");
|
|
184
|
+
add("");
|
|
185
|
+
|
|
186
|
+
let focus: FocusRange | undefined;
|
|
187
|
+
let footerStart: number;
|
|
188
|
+
let wideBody: {
|
|
189
|
+
start: number;
|
|
190
|
+
end: number;
|
|
191
|
+
previewLines: readonly string[];
|
|
192
|
+
layout: PreviewPaneLayout;
|
|
193
|
+
} | undefined;
|
|
194
|
+
const addFooter = (prefix: string, text: string) => {
|
|
195
|
+
// Keep the footer to one physical row. It is fixed chrome, so wrapping
|
|
196
|
+
// it could consume the entire viewport on a narrow terminal.
|
|
197
|
+
add(fit(`${prefix}${text}`, renderWidth));
|
|
198
|
+
};
|
|
199
|
+
if (this.questionnaireState.editor.kind === "select") {
|
|
200
|
+
const preview = this.highlightedPreview();
|
|
201
|
+
const hasPreviews = this.questionnaireState.rows.some(
|
|
202
|
+
row => row.kind === "option" && row.option.preview?.trim(),
|
|
203
|
+
);
|
|
204
|
+
const paneLayout = hasPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
205
|
+
if (paneLayout) {
|
|
206
|
+
const optionLines: string[] = [];
|
|
207
|
+
const optionFocus = this.renderOptions(optionLines, paneLayout.leftWidth);
|
|
208
|
+
const submitFocus = this.renderSubmit(optionLines, paneLayout.leftWidth);
|
|
209
|
+
const sectionStart = lines.length;
|
|
210
|
+
const previewLines = this.renderPreview(preview, paneLayout.rightWidth);
|
|
211
|
+
const bodyHeight = Math.max(optionLines.length, previewLines.length);
|
|
212
|
+
lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
|
|
213
|
+
wideBody = {
|
|
214
|
+
start: sectionStart,
|
|
215
|
+
end: sectionStart + bodyHeight,
|
|
216
|
+
previewLines,
|
|
217
|
+
layout: paneLayout,
|
|
218
|
+
};
|
|
219
|
+
const sectionFocus = submitFocus ?? optionFocus;
|
|
220
|
+
if (sectionFocus) {
|
|
221
|
+
focus = {
|
|
222
|
+
start: sectionStart + sectionFocus.start,
|
|
223
|
+
end: sectionStart + sectionFocus.end,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
const optionFocus = this.renderOptions(lines, renderWidth);
|
|
228
|
+
focus = optionFocus;
|
|
229
|
+
if (hasPreviews) {
|
|
230
|
+
const previewStart = lines.length;
|
|
231
|
+
const previewLines = this.renderPreview(preview, renderWidth);
|
|
232
|
+
lines.push(...previewLines);
|
|
233
|
+
if (preview && optionFocus) {
|
|
234
|
+
const firstContentRow = previewLines.findIndex(line => line.trim().length > 0);
|
|
235
|
+
if (firstContentRow >= 0) {
|
|
236
|
+
focus = {
|
|
237
|
+
start: optionFocus.start,
|
|
238
|
+
end: previewStart + firstContentRow + 1,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const submitFocus = this.renderSubmit(lines, renderWidth);
|
|
244
|
+
focus = submitFocus ?? focus;
|
|
245
|
+
}
|
|
246
|
+
add("");
|
|
247
|
+
footerStart = lines.length;
|
|
248
|
+
addFooter(" ", this.config.theme.fg("dim", this.helpText()));
|
|
249
|
+
} else {
|
|
250
|
+
// Keep the options visible while editing so the comment remains tied to
|
|
251
|
+
// the option it belongs to, and so freeform editing does not feel like a
|
|
252
|
+
// separate prompt.
|
|
253
|
+
this.renderOptions(lines, renderWidth);
|
|
254
|
+
const inputPrefix = ` ${this.config.theme.fg("accent", "↳")} `;
|
|
255
|
+
const continuationPrefix = " ".repeat(visibleWidth(inputPrefix));
|
|
256
|
+
const inputWidth = Math.max(1, renderWidth - visibleWidth(inputPrefix));
|
|
257
|
+
const editorLines = this.editor.render(inputWidth).filter(line => visibleWidth(line) > 0);
|
|
258
|
+
const editorStart = lines.length;
|
|
259
|
+
for (const [index, line] of editorLines.entries()) {
|
|
260
|
+
add(`${index === 0 ? inputPrefix : continuationPrefix}${line}`);
|
|
261
|
+
}
|
|
262
|
+
const editorEnd = lines.length;
|
|
263
|
+
if (editorEnd > editorStart) {
|
|
264
|
+
const cursorLine = editorLines.findIndex(line => line.includes(CURSOR_MARKER));
|
|
265
|
+
const focusedRow = cursorLine >= 0 ? editorStart + cursorLine : editorEnd - 1;
|
|
266
|
+
focus = { start: focusedRow, end: focusedRow + 1 };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The help line is the footer. Keep it adjacent to the bottom border so
|
|
270
|
+
// fitViewport can keep both rows fixed while the editor is focused.
|
|
271
|
+
this.renderSubmit(lines, renderWidth);
|
|
272
|
+
footerStart = lines.length;
|
|
273
|
+
addFooter(continuationPrefix, this.config.theme.fg("dim", this.editorHelpText()));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
add(this.config.theme.fg("border", "─".repeat(renderWidth)));
|
|
277
|
+
const maxRows = Number.isFinite(this.config.tui.terminal.rows)
|
|
278
|
+
? this.config.tui.terminal.rows
|
|
279
|
+
: lines.length;
|
|
280
|
+
const rows: LayoutRow[] = wideBody
|
|
281
|
+
? [
|
|
282
|
+
...lines.slice(0, wideBody.start).map(line => fullRow(line)),
|
|
283
|
+
...lines.slice(wideBody.start, wideBody.end).map(left => splitRow(left)),
|
|
284
|
+
...lines.slice(wideBody.end).map(line => fullRow(line)),
|
|
285
|
+
]
|
|
286
|
+
: lines.map(line => fullRow(line));
|
|
287
|
+
const visibleRows = fitViewport(rows, focus, maxRows, 1, lines.length - footerStart);
|
|
288
|
+
let previewIndex = 0;
|
|
289
|
+
|
|
290
|
+
return visibleRows.map(({ value: row, overflow }) => {
|
|
291
|
+
if (row.kind === "full") {
|
|
292
|
+
return projectFullRow(row.line, overflow, renderWidth);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const left = `${overflowPrefix(overflow)}${row.left}`;
|
|
296
|
+
const previewLine = wideBody?.previewLines[previewIndex++] ?? "";
|
|
297
|
+
return composePreviewRow(
|
|
298
|
+
left,
|
|
299
|
+
previewLine,
|
|
300
|
+
wideBody!.layout,
|
|
301
|
+
this.config.theme.fg("dim", "│"),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Submit the current multi-select answer programmatically. */
|
|
307
|
+
submit(): void {
|
|
308
|
+
if (this.questionnaireState.answer || this.cancelled) return;
|
|
309
|
+
const next = transitionQuestionnaire(this.questionnaireState, { type: "submit" });
|
|
310
|
+
this.applyState(next);
|
|
311
|
+
this.finishIfAnswered(next);
|
|
312
|
+
this.requestRender();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Cancel the ask, invoking onCancel at most once. */
|
|
316
|
+
cancel(): void {
|
|
317
|
+
if (this.questionnaireState.answer || this.cancelled) return;
|
|
318
|
+
this.cancelled = true;
|
|
319
|
+
this.editor.focused = false;
|
|
320
|
+
this.config.onCancel?.();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private renderOptions(lines: string[], width: number): FocusRange | undefined {
|
|
324
|
+
const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
|
|
325
|
+
const styled = color ? this.config.theme.fg(color, text) : text;
|
|
326
|
+
addWrappedWithPrefix(lines, prefix, styled, width);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
let focus: FocusRange | undefined;
|
|
330
|
+
for (const [index, row] of this.questionnaireState.rows.entries()) {
|
|
331
|
+
if (row.kind === "submit") continue;
|
|
332
|
+
const selected = this.questionnaireState.highlightedRow === index;
|
|
333
|
+
const start = lines.length;
|
|
334
|
+
const marker = selected ? this.config.theme.fg("accent", "› ") : " ";
|
|
335
|
+
|
|
336
|
+
if (row.kind === "option") {
|
|
337
|
+
const source = row.option;
|
|
338
|
+
const checked = this.questionnaireState.checked.has(source.label);
|
|
339
|
+
const check = this.questionnaireState.config.allowMultiple
|
|
340
|
+
? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
|
|
341
|
+
: "";
|
|
342
|
+
const comment = this.questionnaireState.comments.has(source.label)
|
|
343
|
+
? this.config.theme.fg("warning", " ✎")
|
|
344
|
+
: "";
|
|
345
|
+
addPrefixed("", `${marker}${check}${source.label}${comment}`, selected ? "accent" : "text");
|
|
346
|
+
if (source.description) addPrefixed(" ", source.description, "muted");
|
|
347
|
+
if (selected) {
|
|
348
|
+
const commentText = this.questionnaireState.comments.get(source.label);
|
|
349
|
+
if (commentText) addPrefixed(" ", `✎ ${commentText}`, "dim");
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
const checked = this.questionnaireState.freeformChecked;
|
|
353
|
+
const check = this.questionnaireState.config.allowMultiple
|
|
354
|
+
? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
|
|
355
|
+
: "";
|
|
356
|
+
const suffix = this.questionnaireState.freeformDraft
|
|
357
|
+
? ` — ${this.questionnaireState.freeformDraft}`
|
|
358
|
+
: "";
|
|
359
|
+
addPrefixed("", `${marker}${check}${this.config.theme.fg(selected ? "accent" : "text", `Type a response…${suffix}`)}`);
|
|
360
|
+
}
|
|
361
|
+
if (selected) focus = { start, end: lines.length };
|
|
362
|
+
}
|
|
363
|
+
return focus;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private renderSubmit(lines: string[], width: number): FocusRange | undefined {
|
|
367
|
+
const submitIndex = this.questionnaireState.rows.findIndex(row => row.kind === "submit");
|
|
368
|
+
if (submitIndex < 0) return undefined;
|
|
369
|
+
const selected = this.questionnaireState.highlightedRow === submitIndex;
|
|
370
|
+
const marker = selected ? this.config.theme.fg("accent", "› ") : " ";
|
|
371
|
+
lines.push("");
|
|
372
|
+
const start = lines.length;
|
|
373
|
+
addWrappedWithPrefix(
|
|
374
|
+
lines,
|
|
375
|
+
"",
|
|
376
|
+
`${marker}${this.config.theme.fg(selected ? "accent" : "text", "[ Submit ]")}`,
|
|
377
|
+
width,
|
|
378
|
+
);
|
|
379
|
+
return selected ? { start, end: lines.length } : undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private helpText(): string {
|
|
383
|
+
const navigate = `${this.keyText("tui.select.up", "↑")}/${this.keyText("tui.select.down", "↓")}/jk navigate`;
|
|
384
|
+
const pages = `${this.keyText("tui.select.pageUp", "PgUp")}/${this.keyText("tui.select.pageDown", "PgDn")} page`;
|
|
385
|
+
const confirm = this.keyText("tui.select.confirm", "Enter");
|
|
386
|
+
const cancel = this.keyText("tui.select.cancel", "Esc");
|
|
387
|
+
const comment = this.matchesAnySelect("c") ? "" : "c comment · ";
|
|
388
|
+
const space = this.matchesAnySelect(" ") ? "" : "/Space";
|
|
389
|
+
if (this.questionnaireState.config.allowMultiple) {
|
|
390
|
+
return `${comment}${navigate} · ${cancel} cancel · ${pages} · ${confirm}${space} toggle · ${confirm} edit response`;
|
|
391
|
+
}
|
|
392
|
+
return `${comment}${navigate} · ${cancel} cancel · ${pages} · ${confirm}${space} select`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private matchesAnySelect(data: string): boolean {
|
|
396
|
+
return SELECT_KEYBINDINGS.some(keybinding => this.matchesSelect(data, keybinding));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private matchesSelect(data: string, keybinding: Keybinding): boolean {
|
|
400
|
+
return this.keybindings.matches(data, keybinding);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private keyText(keybinding: Keybinding, fallback: string): string {
|
|
404
|
+
const keys = this.keybindings.getKeys(keybinding);
|
|
405
|
+
return keys.length > 0 ? keys.map(formatKey).join("/") : fallback;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private editorHelpText(): string {
|
|
409
|
+
const submit = this.keyText("tui.input.submit", "Enter");
|
|
410
|
+
return this.questionnaireState.editor.kind === "comment"
|
|
411
|
+
? `${submit} save comment · Esc discard`
|
|
412
|
+
: `${submit} save response · Esc discard`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private highlightedPreview(): string | undefined {
|
|
416
|
+
if (this.questionnaireState.editor.kind !== "select") return undefined;
|
|
417
|
+
const row = this.questionnaireState.rows[this.questionnaireState.highlightedRow];
|
|
418
|
+
return row?.kind === "option" && row.option.preview?.trim() ? row.option.preview : undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private renderPreview(preview: string | undefined, width: number): string[] {
|
|
422
|
+
const lines = preview ? renderPreviewMarkdown(preview, width) : [];
|
|
423
|
+
let height = this.previewHeightByWidth.get(width);
|
|
424
|
+
if (height === undefined) {
|
|
425
|
+
height = this.questionnaireState.rows.reduce((max, row) => {
|
|
426
|
+
if (row.kind !== "option" || !row.option.preview?.trim()) return max;
|
|
427
|
+
return Math.max(max, renderPreviewMarkdown(row.option.preview, width).length);
|
|
428
|
+
}, 0);
|
|
429
|
+
this.previewHeightByWidth.set(width, height);
|
|
430
|
+
}
|
|
431
|
+
return [...lines, ...Array.from({ length: height - lines.length }, () => "")];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private move(delta: number): void {
|
|
435
|
+
this.applyState(transitionQuestionnaire(this.questionnaireState, { type: "move", delta }));
|
|
436
|
+
this.requestRender();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private confirm(): void {
|
|
440
|
+
this.dispatchCurrentRow("activate");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private dispatchCurrentRow(type: "activate" | "toggle"): void {
|
|
444
|
+
const next = transitionQuestionnaire(this.questionnaireState, { type });
|
|
445
|
+
this.applyState(next);
|
|
446
|
+
this.finishIfAnswered(next);
|
|
447
|
+
this.requestRender();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private applyState(next: QuestionnaireState): void {
|
|
451
|
+
const previousEditor = this.questionnaireState.editor;
|
|
452
|
+
const editorChanged = next.editor.kind !== previousEditor.kind;
|
|
453
|
+
this.questionnaireState = next;
|
|
454
|
+
if (editorChanged) {
|
|
455
|
+
this.editor.focused = this._focused && next.editor.kind !== "select";
|
|
456
|
+
if (previousEditor.kind === "select" && next.editor.kind !== "select") {
|
|
457
|
+
this.editor.setText(next.editor.draft);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private finishIfAnswered(state: QuestionnaireState): void {
|
|
463
|
+
if (!state.answer) return;
|
|
464
|
+
this.editor.focused = false;
|
|
465
|
+
this.config.onSubmit?.(state.answer);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private requestRender(): void {
|
|
469
|
+
this.config.tui.requestRender();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const SELECT_KEYBINDINGS: readonly Keybinding[] = [
|
|
474
|
+
"tui.select.cancel",
|
|
475
|
+
"tui.select.up",
|
|
476
|
+
"tui.select.down",
|
|
477
|
+
"tui.select.pageUp",
|
|
478
|
+
"tui.select.pageDown",
|
|
479
|
+
"tui.select.confirm",
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
function editorTheme(theme: Theme): EditorTheme {
|
|
483
|
+
return {
|
|
484
|
+
borderColor: () => "",
|
|
485
|
+
selectList: {
|
|
486
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
487
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
488
|
+
description: (text) => theme.fg("muted", text),
|
|
489
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
490
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isLiteral(data: string, expected: string): boolean {
|
|
496
|
+
return data === expected || parseKey(data) === expected;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function formatKey(key: string): string {
|
|
500
|
+
const aliases: Record<string, string> = {
|
|
501
|
+
up: "↑",
|
|
502
|
+
down: "↓",
|
|
503
|
+
pageUp: "PgUp",
|
|
504
|
+
pageDown: "PgDn",
|
|
505
|
+
escape: "Esc",
|
|
506
|
+
enter: "Enter",
|
|
507
|
+
space: "Space",
|
|
508
|
+
home: "Home",
|
|
509
|
+
end: "End",
|
|
510
|
+
};
|
|
511
|
+
return key.split("+").map(part => aliases[part] ?? part).join("+");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
type LayoutRow =
|
|
515
|
+
| { kind: "full"; line: string }
|
|
516
|
+
| { kind: "split"; left: string };
|
|
517
|
+
|
|
518
|
+
function fullRow(line: string): LayoutRow {
|
|
519
|
+
return { kind: "full", line };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function splitRow(left: string): LayoutRow {
|
|
523
|
+
return { kind: "split", left };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function overflowPrefix(overflow: ViewportOverflow | undefined): string {
|
|
527
|
+
if (overflow === "above") return "↑ ";
|
|
528
|
+
if (overflow === "below") return "↓ ";
|
|
529
|
+
if (overflow === "both") return "↕ ";
|
|
530
|
+
return "";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function safeWidth(width: number): number {
|
|
534
|
+
return Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function projectFullRow(
|
|
538
|
+
line: string,
|
|
539
|
+
overflow: ViewportOverflow | undefined,
|
|
540
|
+
width: number,
|
|
541
|
+
): string {
|
|
542
|
+
const prefixed = `${overflowPrefix(overflow)}${line}`;
|
|
543
|
+
// The cursor and current input take precedence over an overflow indicator
|
|
544
|
+
// when both cannot fit on a narrow terminal row.
|
|
545
|
+
if (overflow && visibleWidth(prefixed) > width && line.includes(CURSOR_MARKER)) {
|
|
546
|
+
return fit(line, width);
|
|
547
|
+
}
|
|
548
|
+
return fit(prefixed, width);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function fit(line: string, width: number): string {
|
|
552
|
+
return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function addWrappedWithPrefix(lines: string[], prefix: string, text: string, width: number): void {
|
|
556
|
+
const prefixWidth = visibleWidth(prefix);
|
|
557
|
+
if (prefixWidth >= width) {
|
|
558
|
+
for (const line of wrapTextWithAnsi(`${prefix}${text}`, width)) {
|
|
559
|
+
lines.push(fit(line, width));
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const available = Math.max(1, width - prefixWidth);
|
|
565
|
+
const wrapped = wrapTextWithAnsi(text, available);
|
|
566
|
+
const continuation = " ".repeat(prefixWidth);
|
|
567
|
+
for (let index = 0; index < wrapped.length; index += 1) {
|
|
568
|
+
lines.push(fit(`${index === 0 ? prefix : continuation}${wrapped[index]}`, width));
|
|
569
|
+
}
|
|
570
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
2
|
+
|
|
3
|
+
type AskEnvironment = Readonly<{
|
|
4
|
+
PI9_ASK_TIMEOUT_MS?: string;
|
|
5
|
+
}>;
|
|
6
|
+
|
|
7
|
+
export function resolveTimeoutMs(
|
|
8
|
+
perCallTimeout: number | undefined,
|
|
9
|
+
env: AskEnvironment,
|
|
10
|
+
): number | undefined {
|
|
11
|
+
if (perCallTimeout !== undefined) {
|
|
12
|
+
return Number.isFinite(perCallTimeout)
|
|
13
|
+
&& Number.isInteger(perCallTimeout)
|
|
14
|
+
&& perCallTimeout > 0
|
|
15
|
+
&& perCallTimeout <= MAX_TIMEOUT_MS
|
|
16
|
+
? perCallTimeout
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const envTimeout = env.PI9_ASK_TIMEOUT_MS;
|
|
21
|
+
if (envTimeout === undefined || !/^\d+$/.test(envTimeout)) return undefined;
|
|
22
|
+
|
|
23
|
+
const timeout = Number(envTimeout);
|
|
24
|
+
return Number.isFinite(timeout)
|
|
25
|
+
&& Number.isInteger(timeout)
|
|
26
|
+
&& timeout > 0
|
|
27
|
+
&& timeout <= MAX_TIMEOUT_MS
|
|
28
|
+
? timeout
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|