pi-soly 0.3.0 → 0.5.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/ask/README.md +135 -0
- package/ask/index.ts +218 -0
- package/ask/package.json +51 -0
- package/ask/picker.ts +686 -0
- package/ask/prompt.ts +37 -0
- package/ask/tests/picker.test.ts +588 -0
- package/ask/tests/prompt.test.ts +54 -0
- package/ask/tsconfig.json +28 -0
- package/index.ts +8 -0
- package/package.json +6 -2
- package/switch/README.md +107 -0
- package/switch/core.ts +202 -0
- package/switch/index.ts +300 -0
- package/switch/package.json +52 -0
- package/switch/prompt.ts +134 -0
- package/switch/tests/core.test.ts +188 -0
- package/switch/tests/index.test.ts +47 -0
- package/switch/tests/prompt.test.ts +106 -0
- package/switch/tsconfig.json +28 -0
package/ask/picker.ts
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picker.ts — Claude Code-style multi-question picker TUI component
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Renders a tabbed multi-question flow inside pi's TUI. One `ask_pro` tool
|
|
6
|
+
// call can show N questions; the user navigates between them with Tab/arrows
|
|
7
|
+
// or instant-picks with 1-N. For multi-select questions, Enter toggles and
|
|
8
|
+
// the last question's Enter submits. For single-select (default), Enter on
|
|
9
|
+
// an option auto-advances to the next question (or submits on the last).
|
|
10
|
+
//
|
|
11
|
+
// Per-question `allowOther: true` appends a synthetic "Other…" option that
|
|
12
|
+
// opens a text-input dialog when picked. The custom string is stored as the
|
|
13
|
+
// answer (string for single-select, pushed into the array for multi-select).
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Container,
|
|
18
|
+
Text,
|
|
19
|
+
Spacer,
|
|
20
|
+
type Component,
|
|
21
|
+
type KeybindingsManager,
|
|
22
|
+
} from "@earendil-works/pi-tui";
|
|
23
|
+
|
|
24
|
+
/** Minimal theme shape we need. Matches pi-coding-agent's Theme.fg / .bold. */
|
|
25
|
+
export interface AskProTheme {
|
|
26
|
+
fg: (color: string, text: string) => string;
|
|
27
|
+
bold: (text: string) => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AskOption {
|
|
31
|
+
label: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
recommended?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AskQuestion {
|
|
37
|
+
/** Short label shown in the tab (e.g. "Auth", "Tokens"). 1-2 words. */
|
|
38
|
+
header: string;
|
|
39
|
+
/** The full question. */
|
|
40
|
+
question: string;
|
|
41
|
+
/** 2-4 options. */
|
|
42
|
+
options: AskOption[];
|
|
43
|
+
/** If true, user can pick multiple options (checkboxes). Default false. */
|
|
44
|
+
multiSelect?: boolean;
|
|
45
|
+
/** If true, append a synthetic "Other…" option that opens a text-input
|
|
46
|
+
* dialog when picked. The custom string is stored as the answer.
|
|
47
|
+
* Default false. */
|
|
48
|
+
allowOther?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Single-pick answer: either an option index (0..N-1) or a custom string
|
|
52
|
+
* (when "Other…" was picked and the user typed something). */
|
|
53
|
+
export type AskAnswer = number | string;
|
|
54
|
+
/** Multi-pick answer: a heterogeneous array of option indices + custom strings. */
|
|
55
|
+
export type AskMultiAnswer = Array<AskAnswer>;
|
|
56
|
+
|
|
57
|
+
export interface AskProResult {
|
|
58
|
+
/** Set if the user cancelled (Esc). Other fields are absent. */
|
|
59
|
+
cancelled?: boolean;
|
|
60
|
+
/** Map of question index → answer. Single: number | string. Multi: (number | string)[] */
|
|
61
|
+
answers?: Record<number, AskAnswer | AskMultiAnswer>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Options for the text-input dialog opened when "Other…" is picked. */
|
|
65
|
+
export interface AskProInputRequest {
|
|
66
|
+
title: string;
|
|
67
|
+
prompt: string;
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AskProComponentDeps {
|
|
72
|
+
questions: AskQuestion[];
|
|
73
|
+
theme: AskProTheme;
|
|
74
|
+
keybindings: KeybindingsManager;
|
|
75
|
+
done: (result: AskProResult) => void;
|
|
76
|
+
/** Optional title shown above the tabs. */
|
|
77
|
+
title?: string;
|
|
78
|
+
/** Open a text-input dialog for the "Other…" option. Returns the typed
|
|
79
|
+
* text, or undefined if the user cancelled. If omitted, the "Other…"
|
|
80
|
+
* option is hidden even when `allowOther: true` (caller should ensure
|
|
81
|
+
* the dependency is present if it advertises allowOther). */
|
|
82
|
+
onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Keycode constants — pi uses raw escape sequences for arrows and a few other
|
|
87
|
+
// special keys. Tab is a single \t. Enter is \n or \r. Esc is \x1b.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
const KEY_ESC = "\x1b";
|
|
91
|
+
const KEY_TAB = "\t";
|
|
92
|
+
const KEY_ENTER = "\n";
|
|
93
|
+
const KEY_ENTER_CR = "\r";
|
|
94
|
+
const KEY_SPACE = " ";
|
|
95
|
+
const KEY_UP = "\x1b[A";
|
|
96
|
+
const KEY_DOWN = "\x1b[B";
|
|
97
|
+
const KEY_RIGHT = "\x1b[C";
|
|
98
|
+
const KEY_LEFT = "\x1b[D";
|
|
99
|
+
const KEY_SHIFT_TAB = "\x1b[Z";
|
|
100
|
+
const KEY_BACKSPACE = "\x7f";
|
|
101
|
+
|
|
102
|
+
/** A standalone picker component. Extends Container so it composes in the
|
|
103
|
+
* editor area like any other TUI widget. */
|
|
104
|
+
export class AskProComponent extends Container {
|
|
105
|
+
private questions: AskQuestion[];
|
|
106
|
+
private theme: AskProTheme;
|
|
107
|
+
private keybindings: KeybindingsManager;
|
|
108
|
+
private done: (result: AskProResult) => void;
|
|
109
|
+
private onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
110
|
+
private title: string;
|
|
111
|
+
|
|
112
|
+
private currentIndex = 0;
|
|
113
|
+
private selectedIndex = 0;
|
|
114
|
+
/** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
|
|
115
|
+
private answers = new Map<number, AskAnswer | AskMultiAnswer>();
|
|
116
|
+
/** Set true once `done` is called — further input is ignored. */
|
|
117
|
+
private completed = false;
|
|
118
|
+
/** Set while a text-input dialog is awaiting the user's reply. */
|
|
119
|
+
private awaitingInput = false;
|
|
120
|
+
|
|
121
|
+
private tabsText!: Text;
|
|
122
|
+
private bodyContainer!: Container;
|
|
123
|
+
private footerText!: Text;
|
|
124
|
+
|
|
125
|
+
constructor(deps: AskProComponentDeps) {
|
|
126
|
+
super();
|
|
127
|
+
this.questions = deps.questions;
|
|
128
|
+
this.theme = deps.theme;
|
|
129
|
+
this.keybindings = deps.keybindings;
|
|
130
|
+
this.done = deps.done;
|
|
131
|
+
this.onRequestInput = deps.onRequestInput;
|
|
132
|
+
this.title = deps.title ?? "pi-ask";
|
|
133
|
+
|
|
134
|
+
const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
|
|
135
|
+
this.addChild(titleText);
|
|
136
|
+
this.addChild(new Spacer(1));
|
|
137
|
+
|
|
138
|
+
this.tabsText = new Text("", 1, 0);
|
|
139
|
+
this.addChild(this.tabsText);
|
|
140
|
+
this.addChild(new Spacer(1));
|
|
141
|
+
|
|
142
|
+
this.bodyContainer = new Container();
|
|
143
|
+
this.addChild(this.bodyContainer);
|
|
144
|
+
|
|
145
|
+
this.addChild(new Spacer(1));
|
|
146
|
+
this.footerText = new Text("", 1, 0);
|
|
147
|
+
this.addChild(this.footerText);
|
|
148
|
+
|
|
149
|
+
this.repaint();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
// Public state accessors (used by tests; safe to call from outside)
|
|
154
|
+
// -------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
getCurrentIndex(): number {
|
|
157
|
+
return this.currentIndex;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getSelectedIndex(): number {
|
|
161
|
+
return this.selectedIndex;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getAnswers(): Map<number, AskAnswer | AskMultiAnswer> {
|
|
165
|
+
return new Map(this.answers);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
// Rendering — updates the Text/Container children; the TUI re-renders
|
|
170
|
+
// the whole tree on its next render cycle, picking up our changes.
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
private repaint(): void {
|
|
174
|
+
this.tabsText.setText(this.renderTabs());
|
|
175
|
+
this.renderQuestionBody();
|
|
176
|
+
this.footerText.setText(this.renderFooter());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private renderTabs(): string {
|
|
180
|
+
return this.questions
|
|
181
|
+
.map((q, i) => {
|
|
182
|
+
const answered = this.isAnswered(i);
|
|
183
|
+
const active = i === this.currentIndex;
|
|
184
|
+
const marker = active ? "◉" : answered ? "✓" : "○";
|
|
185
|
+
const label = q.header.length > 12 ? `${q.header.slice(0, 11)}…` : q.header;
|
|
186
|
+
const color = active ? "accent" : answered ? "success" : "dim";
|
|
187
|
+
return this.theme.fg(color, `${marker} ${label}`);
|
|
188
|
+
})
|
|
189
|
+
.join(this.theme.fg("dim", " "));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private renderQuestionBody(): void {
|
|
193
|
+
this.bodyContainer.clear();
|
|
194
|
+
const q = this.questions[this.currentIndex];
|
|
195
|
+
if (!q) return;
|
|
196
|
+
|
|
197
|
+
// Question line: "Q1 of 3: <question>"
|
|
198
|
+
this.bodyContainer.addChild(
|
|
199
|
+
new Text(
|
|
200
|
+
this.theme.fg("dim", `Q${this.currentIndex + 1} of ${this.questions.length}: `) +
|
|
201
|
+
this.theme.bold(q.question),
|
|
202
|
+
1,
|
|
203
|
+
0,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
207
|
+
|
|
208
|
+
const isMulti = q.multiSelect ?? false;
|
|
209
|
+
const allowOther = q.allowOther ?? false;
|
|
210
|
+
const currentAns = this.answers.get(this.currentIndex);
|
|
211
|
+
|
|
212
|
+
// Real options
|
|
213
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
214
|
+
const opt = q.options[i];
|
|
215
|
+
if (!opt) continue;
|
|
216
|
+
const isSelected = i === this.selectedIndex;
|
|
217
|
+
|
|
218
|
+
// Cursor
|
|
219
|
+
const cursor = isSelected ? this.theme.fg("accent", "❯ ") : " ";
|
|
220
|
+
|
|
221
|
+
// Checkbox (multi) or radio (single)
|
|
222
|
+
let prefix: string;
|
|
223
|
+
if (isMulti) {
|
|
224
|
+
const isChecked = this.isIndexChecked(currentAns, i);
|
|
225
|
+
prefix = (isChecked ? "☒" : "☐") + " ";
|
|
226
|
+
prefix = this.theme.fg(isChecked ? "success" : "dim", prefix);
|
|
227
|
+
} else {
|
|
228
|
+
const isChosen = currentAns === i;
|
|
229
|
+
prefix = (isChosen ? "●" : "○") + " ";
|
|
230
|
+
prefix = this.theme.fg(isChosen ? "success" : "dim", prefix);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ⭐ prefix for recommended
|
|
234
|
+
const star = opt.recommended ? this.theme.fg("warning", "⭐ ") : "";
|
|
235
|
+
|
|
236
|
+
// Label — accent if selected, text otherwise
|
|
237
|
+
const labelText = `${star}${opt.label}`;
|
|
238
|
+
const label = this.theme.fg(isSelected ? "accent" : "text", labelText);
|
|
239
|
+
|
|
240
|
+
this.bodyContainer.addChild(new Text(cursor + prefix + label, 1, 0));
|
|
241
|
+
|
|
242
|
+
// Description on its own line
|
|
243
|
+
if (opt.description) {
|
|
244
|
+
this.bodyContainer.addChild(
|
|
245
|
+
new Text(" " + this.theme.fg("dim", opt.description), 1, 0),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Synthetic "Other…" option (when allowOther=true)
|
|
251
|
+
if (allowOther && this.onRequestInput) {
|
|
252
|
+
const otherIndex = q.options.length;
|
|
253
|
+
const isOtherSelected = this.selectedIndex === otherIndex;
|
|
254
|
+
const customStr = this.getCustomString(currentAns);
|
|
255
|
+
|
|
256
|
+
const cursor = isOtherSelected ? this.theme.fg("accent", "❯ ") : " ";
|
|
257
|
+
|
|
258
|
+
if (isMulti) {
|
|
259
|
+
const isChecked = this.isCustomStringChecked(currentAns);
|
|
260
|
+
const prefix = (isChecked ? "☒" : "☐") + " ";
|
|
261
|
+
const prefixStyled = this.theme.fg(isChecked ? "success" : "dim", prefix);
|
|
262
|
+
const labelInner = customStr
|
|
263
|
+
? `Other: ${this.theme.bold(customStr)}`
|
|
264
|
+
: "Other…";
|
|
265
|
+
const labelStyled = this.theme.fg(
|
|
266
|
+
isOtherSelected ? "accent" : "text",
|
|
267
|
+
labelInner,
|
|
268
|
+
);
|
|
269
|
+
this.bodyContainer.addChild(
|
|
270
|
+
new Text(cursor + prefixStyled + labelStyled, 1, 0),
|
|
271
|
+
);
|
|
272
|
+
if (isOtherSelected && !customStr) {
|
|
273
|
+
this.bodyContainer.addChild(
|
|
274
|
+
new Text(
|
|
275
|
+
" " +
|
|
276
|
+
this.theme.fg("dim", "(press Enter to type a custom answer)"),
|
|
277
|
+
1,
|
|
278
|
+
0,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
const isChosen = typeof currentAns === "string";
|
|
284
|
+
const prefix = (isChosen ? "●" : "○") + " ";
|
|
285
|
+
const prefixStyled = this.theme.fg(isChosen ? "success" : "dim", prefix);
|
|
286
|
+
const labelInner = customStr
|
|
287
|
+
? `Other: ${this.theme.bold(customStr)}`
|
|
288
|
+
: "Other…";
|
|
289
|
+
const labelStyled = this.theme.fg(
|
|
290
|
+
isOtherSelected ? "accent" : "text",
|
|
291
|
+
labelInner,
|
|
292
|
+
);
|
|
293
|
+
this.bodyContainer.addChild(
|
|
294
|
+
new Text(cursor + prefixStyled + labelStyled, 1, 0),
|
|
295
|
+
);
|
|
296
|
+
if (isOtherSelected && !customStr) {
|
|
297
|
+
this.bodyContainer.addChild(
|
|
298
|
+
new Text(
|
|
299
|
+
" " +
|
|
300
|
+
this.theme.fg("dim", "(press Enter to type a custom answer)"),
|
|
301
|
+
1,
|
|
302
|
+
0,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// For multiSelect on the LAST question, also show a "Submit" row at
|
|
310
|
+
// the bottom (visual hint — pressing Enter on it submits).
|
|
311
|
+
if (isMulti && this.currentIndex === this.questions.length - 1) {
|
|
312
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
313
|
+
const allAnswered = this.allAnswered();
|
|
314
|
+
const submitLabel = allAnswered ? "▶ Submit answers" : "▶ Submit (need to answer all)";
|
|
315
|
+
this.bodyContainer.addChild(
|
|
316
|
+
new Text(
|
|
317
|
+
this.theme.fg(allAnswered ? "accent" : "dim", submitLabel),
|
|
318
|
+
1,
|
|
319
|
+
0,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
// State helpers
|
|
327
|
+
// -------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** Is option `idx` currently in the answer (multi only)? */
|
|
330
|
+
private isIndexChecked(ans: AskAnswer | AskMultiAnswer | undefined, idx: number): boolean {
|
|
331
|
+
if (ans === undefined) return false;
|
|
332
|
+
if (typeof ans === "number" || typeof ans === "string") return false;
|
|
333
|
+
return (ans as AskMultiAnswer).includes(idx);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Is a custom string currently in the multi answer? */
|
|
337
|
+
private isCustomStringChecked(ans: AskAnswer | AskMultiAnswer | undefined): boolean {
|
|
338
|
+
if (ans === undefined) return false;
|
|
339
|
+
if (typeof ans === "number" || typeof ans === "string") return false;
|
|
340
|
+
return (ans as AskMultiAnswer).some((a) => typeof a === "string");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Extract the current custom string (for single-pick or the first
|
|
344
|
+
* string in a multi answer). Returns "" if no custom string. */
|
|
345
|
+
private getCustomString(ans: AskAnswer | AskMultiAnswer | undefined): string {
|
|
346
|
+
if (ans === undefined) return "";
|
|
347
|
+
if (typeof ans === "string") return ans;
|
|
348
|
+
const found = (ans as AskMultiAnswer).find((a) => typeof a === "string");
|
|
349
|
+
return typeof found === "string" ? found : "";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Total number of selectable rows for the current question (options + Other). */
|
|
353
|
+
private totalOptionsForCurrent(): number {
|
|
354
|
+
const q = this.questions[this.currentIndex];
|
|
355
|
+
if (!q) return 0;
|
|
356
|
+
const allowOther = q.allowOther ?? false;
|
|
357
|
+
return q.options.length + (allowOther && this.onRequestInput ? 1 : 0);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
// Rendering — tabs and footer
|
|
362
|
+
// -------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
private renderFooter(): string {
|
|
365
|
+
const q = this.questions[this.currentIndex];
|
|
366
|
+
if (!q) return "";
|
|
367
|
+
const isMulti = q.multiSelect ?? false;
|
|
368
|
+
const isLast = this.currentIndex === this.questions.length - 1;
|
|
369
|
+
const allowOther = q.allowOther ?? false;
|
|
370
|
+
const otherIndex = allowOther ? q.options.length : -1;
|
|
371
|
+
const totalOptions = this.totalOptionsForCurrent();
|
|
372
|
+
|
|
373
|
+
const parts: string[] = [];
|
|
374
|
+
parts.push(this.theme.fg("dim", "↑↓ navigate"));
|
|
375
|
+
parts.push(this.theme.fg("dim", `1-${totalOptions} pick`));
|
|
376
|
+
if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
|
|
377
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
378
|
+
parts.push(this.theme.fg("dim", "tab/→ next"));
|
|
379
|
+
}
|
|
380
|
+
// "Other…" hint: single-select uses Enter, multi-select uses Space
|
|
381
|
+
if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
|
|
382
|
+
parts.push(this.theme.fg("accent", isMulti ? "␣ type" : "⏎ type"));
|
|
383
|
+
} else if (isMulti) {
|
|
384
|
+
// Multi-select: Space toggles, Enter advances/submits
|
|
385
|
+
parts.push(this.theme.fg("dim", "␣ toggle"));
|
|
386
|
+
if (isLast) {
|
|
387
|
+
parts.push(
|
|
388
|
+
this.theme.fg(
|
|
389
|
+
this.allAnswered() ? "accent" : "dim",
|
|
390
|
+
this.allAnswered() ? "⏎ submit" : "⏎ (answer all)",
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
} else {
|
|
394
|
+
parts.push(this.theme.fg("dim", "⏎ next"));
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
// Single-select: Enter is the action key
|
|
398
|
+
parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
|
|
399
|
+
}
|
|
400
|
+
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
401
|
+
return parts.join(" ");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private isAnswered(qIdx: number): boolean {
|
|
405
|
+
const a = this.answers.get(qIdx);
|
|
406
|
+
if (a === undefined) return false;
|
|
407
|
+
if (Array.isArray(a)) return a.length > 0;
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private allAnswered(): boolean {
|
|
412
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
413
|
+
if (!this.isAnswered(i)) return false;
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// -------------------------------------------------------------------------
|
|
419
|
+
// Key handling
|
|
420
|
+
// -------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
handleInput(keyData: string): void {
|
|
423
|
+
if (this.completed || this.awaitingInput) return;
|
|
424
|
+
|
|
425
|
+
// Esc — cancel
|
|
426
|
+
if (keyData === KEY_ESC) {
|
|
427
|
+
this.completed = true;
|
|
428
|
+
this.done({ cancelled: true });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const q = this.questions[this.currentIndex];
|
|
433
|
+
if (!q) return;
|
|
434
|
+
const isMulti = q.multiSelect ?? false;
|
|
435
|
+
const allowOther = q.allowOther ?? false;
|
|
436
|
+
const otherIndex = allowOther ? q.options.length : -1;
|
|
437
|
+
const totalOptions = this.totalOptionsForCurrent();
|
|
438
|
+
|
|
439
|
+
// Arrow up / k — move selection up
|
|
440
|
+
if (
|
|
441
|
+
this.keybindings.matches(keyData, "tui.select.up") ||
|
|
442
|
+
keyData === "k" ||
|
|
443
|
+
keyData === KEY_UP
|
|
444
|
+
) {
|
|
445
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
446
|
+
this.repaint();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Arrow down / j — move selection down
|
|
451
|
+
if (
|
|
452
|
+
this.keybindings.matches(keyData, "tui.select.down") ||
|
|
453
|
+
keyData === "j" ||
|
|
454
|
+
keyData === KEY_DOWN
|
|
455
|
+
) {
|
|
456
|
+
this.selectedIndex = Math.min(totalOptions - 1, this.selectedIndex + 1);
|
|
457
|
+
this.repaint();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Tab / Right arrow — next question
|
|
462
|
+
if (keyData === KEY_TAB || keyData === KEY_RIGHT) {
|
|
463
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
464
|
+
this.currentIndex++;
|
|
465
|
+
this.selectedIndex = 0;
|
|
466
|
+
this.repaint();
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Shift+Tab / Left arrow — prev question
|
|
472
|
+
if (keyData === KEY_SHIFT_TAB || keyData === KEY_LEFT) {
|
|
473
|
+
if (this.currentIndex > 0) {
|
|
474
|
+
this.currentIndex--;
|
|
475
|
+
this.selectedIndex = 0;
|
|
476
|
+
this.repaint();
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Backspace — also prev question (common convention)
|
|
482
|
+
if (keyData === KEY_BACKSPACE && this.currentIndex > 0) {
|
|
483
|
+
this.currentIndex--;
|
|
484
|
+
this.selectedIndex = 0;
|
|
485
|
+
this.repaint();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Number keys 1-N — instant pick (including "Other…" at position N+1)
|
|
490
|
+
const num = parseInt(keyData, 10);
|
|
491
|
+
if (!isNaN(num) && num >= 1 && num <= totalOptions) {
|
|
492
|
+
this.handlePick(num - 1);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Space — toggle in multi-select (Claude Code convention).
|
|
497
|
+
// On "Other…", opens the input dialog (or toggles existing custom string).
|
|
498
|
+
// In single-select, Space is a no-op (Enter is the action key there).
|
|
499
|
+
if (keyData === KEY_SPACE) {
|
|
500
|
+
if (!isMulti) return;
|
|
501
|
+
// On Other… → open input dialog (or re-toggle existing custom string)
|
|
502
|
+
if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
|
|
503
|
+
void this.requestOtherInput();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
507
|
+
const idx = cur.indexOf(this.selectedIndex);
|
|
508
|
+
if (idx === -1) cur.push(this.selectedIndex);
|
|
509
|
+
else cur.splice(idx, 1);
|
|
510
|
+
this.answers.set(this.currentIndex, cur);
|
|
511
|
+
this.repaint();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Enter — confirm / advance / submit (universal confirm gesture).
|
|
516
|
+
// In single-select: picks the option, then advances or submits.
|
|
517
|
+
// In multi-select: skips toggle (use Space for that); just advances
|
|
518
|
+
// or submits. On the LAST question, if all answered, Enter submits.
|
|
519
|
+
if (
|
|
520
|
+
this.keybindings.matches(keyData, "tui.select.confirm") ||
|
|
521
|
+
keyData === KEY_ENTER ||
|
|
522
|
+
keyData === KEY_ENTER_CR
|
|
523
|
+
) {
|
|
524
|
+
// If "Other…" is the selected option in single-select, open
|
|
525
|
+
// the input dialog. In multi-select, Enter on Other… just
|
|
526
|
+
// advances (use Space to toggle/type a custom answer).
|
|
527
|
+
if (
|
|
528
|
+
allowOther &&
|
|
529
|
+
this.onRequestInput &&
|
|
530
|
+
this.selectedIndex === otherIndex &&
|
|
531
|
+
!isMulti
|
|
532
|
+
) {
|
|
533
|
+
void this.requestOtherInput();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (isMulti) {
|
|
538
|
+
// On the LAST question, if all questions are answered, Enter
|
|
539
|
+
// submits. Otherwise it advances (if not last) or stays put
|
|
540
|
+
// (on last + not all answered — user must finish first).
|
|
541
|
+
if (
|
|
542
|
+
this.currentIndex === this.questions.length - 1 &&
|
|
543
|
+
this.allAnswered()
|
|
544
|
+
) {
|
|
545
|
+
this.submit();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
549
|
+
this.currentIndex++;
|
|
550
|
+
this.selectedIndex = 0;
|
|
551
|
+
}
|
|
552
|
+
this.repaint();
|
|
553
|
+
} else {
|
|
554
|
+
// Single-select: set current as answer, then advance or submit
|
|
555
|
+
this.answers.set(this.currentIndex, this.selectedIndex);
|
|
556
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
557
|
+
this.currentIndex++;
|
|
558
|
+
this.selectedIndex = 0;
|
|
559
|
+
this.repaint();
|
|
560
|
+
} else if (this.allAnswered()) {
|
|
561
|
+
this.submit();
|
|
562
|
+
} else {
|
|
563
|
+
this.repaint();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private handlePick(optionIdx: number): void {
|
|
571
|
+
const q = this.questions[this.currentIndex];
|
|
572
|
+
if (!q) return;
|
|
573
|
+
const isMulti = q.multiSelect ?? false;
|
|
574
|
+
const allowOther = q.allowOther ?? false;
|
|
575
|
+
const otherIndex = allowOther ? q.options.length : -1;
|
|
576
|
+
|
|
577
|
+
// "Other…" picked via number key
|
|
578
|
+
if (allowOther && this.onRequestInput && optionIdx === otherIndex) {
|
|
579
|
+
void this.requestOtherInput();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (isMulti) {
|
|
584
|
+
const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
585
|
+
const idx = cur.indexOf(optionIdx);
|
|
586
|
+
if (idx === -1) cur.push(optionIdx);
|
|
587
|
+
else cur.splice(idx, 1);
|
|
588
|
+
this.answers.set(this.currentIndex, cur);
|
|
589
|
+
this.repaint();
|
|
590
|
+
} else {
|
|
591
|
+
this.answers.set(this.currentIndex, optionIdx);
|
|
592
|
+
this.selectedIndex = optionIdx;
|
|
593
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
594
|
+
// Advance to next question; don't submit yet
|
|
595
|
+
this.currentIndex++;
|
|
596
|
+
this.selectedIndex = 0;
|
|
597
|
+
this.repaint();
|
|
598
|
+
} else if (this.allAnswered()) {
|
|
599
|
+
// Last question + all answered → submit
|
|
600
|
+
this.submit();
|
|
601
|
+
} else {
|
|
602
|
+
this.repaint();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Open the text-input dialog for the "Other…" option. Awaits the user's
|
|
609
|
+
* reply asynchronously. While awaiting, the picker ignores further input.
|
|
610
|
+
* If the user types text, the answer is stored (string for single, pushed
|
|
611
|
+
* to the multi-select array for multi). If the user cancels, the answer
|
|
612
|
+
* is unchanged.
|
|
613
|
+
*/
|
|
614
|
+
private async requestOtherInput(): Promise<void> {
|
|
615
|
+
if (!this.onRequestInput) return;
|
|
616
|
+
const q = this.questions[this.currentIndex];
|
|
617
|
+
if (!q) return;
|
|
618
|
+
this.awaitingInput = true;
|
|
619
|
+
const isMulti = q.multiSelect ?? false;
|
|
620
|
+
|
|
621
|
+
const text = await this.onRequestInput({
|
|
622
|
+
title: q.header,
|
|
623
|
+
prompt: `Custom answer for: ${q.question}`,
|
|
624
|
+
placeholder: "Type your answer…",
|
|
625
|
+
});
|
|
626
|
+
this.awaitingInput = false;
|
|
627
|
+
|
|
628
|
+
if (text === undefined) {
|
|
629
|
+
// User cancelled — leave answer as-is, just redraw
|
|
630
|
+
this.repaint();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const trimmed = text.trim();
|
|
634
|
+
if (trimmed === "") {
|
|
635
|
+
this.repaint();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (isMulti) {
|
|
640
|
+
const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
641
|
+
// Replace existing custom string (if any) so user can edit
|
|
642
|
+
const existingIdx = cur.findIndex((a) => typeof a === "string");
|
|
643
|
+
if (existingIdx >= 0) cur[existingIdx] = trimmed;
|
|
644
|
+
else cur.push(trimmed);
|
|
645
|
+
this.answers.set(this.currentIndex, cur);
|
|
646
|
+
this.repaint();
|
|
647
|
+
} else {
|
|
648
|
+
// Single-select: set custom string, advance or submit
|
|
649
|
+
this.answers.set(this.currentIndex, trimmed);
|
|
650
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
651
|
+
this.currentIndex++;
|
|
652
|
+
this.selectedIndex = 0;
|
|
653
|
+
this.repaint();
|
|
654
|
+
} else if (this.allAnswered()) {
|
|
655
|
+
this.submit();
|
|
656
|
+
} else {
|
|
657
|
+
this.repaint();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private submit(): void {
|
|
663
|
+
if (!this.allAnswered()) return;
|
|
664
|
+
const answers: Record<number, AskAnswer | AskMultiAnswer> = {};
|
|
665
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
666
|
+
answers[i] = this.answers.get(i) as AskAnswer | AskMultiAnswer;
|
|
667
|
+
}
|
|
668
|
+
this.completed = true;
|
|
669
|
+
this.done({ answers });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// -------------------------------------------------------------------------
|
|
673
|
+
// Public no-op dispose (Container doesn't define one; we just stop taking
|
|
674
|
+
// input). TUI will tear down children when the parent is disposed.
|
|
675
|
+
// -------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
dispose(): void {
|
|
678
|
+
this.completed = true;
|
|
679
|
+
this.awaitingInput = false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Type guard for the public component. */
|
|
684
|
+
export function isAskProComponent(c: Component): c is AskProComponent {
|
|
685
|
+
return c instanceof AskProComponent;
|
|
686
|
+
}
|