Repository navigation
Expand file tree
/
Copy pathcliPrompt.ts
More file actions
555 lines (523 loc) · 20.6 KB
/
Copy pathcliPrompt.ts
File metadata and controls
555 lines (523 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
import readline from 'node:readline';
import { getAmbientChat } from './ink/ambientChat.js';
/**
* Shared bridge between the REPL's readline interface and modules outside
* repl.ts that need to (a) write above the prompt without scrambling input,
* or (b) ask the user a one-shot question while a turn is in progress
* (e.g. run_command approval).
*
* Previously this used `inquirer.prompt`. Inquirer creates its OWN readline
* interface attached to the same `process.stdin`, and on exit it leaves stray
* `line` events that the parent REPL then sees as "the user typed a new
* prompt while a turn is still running". Using the parent rl directly avoids
* that.
*
* Only one REPL exists per process, so a module-level pointer is fine.
*/
let activeReadline: readline.Interface | undefined;
export function setActiveReadline(rl: readline.Interface | undefined): void {
activeReadline = rl;
}
export function getActiveReadline(): readline.Interface | undefined {
return activeReadline;
}
/**
* True while `askChoice` is rendering its raw-mode picker. The REPL's own
* keypress handler (shift+tab access-mode cycle) checks this and yields,
* so the picker has uncontested control of stdin while it's active.
*/
let pickerActive = false;
export function isPickerActive(): boolean { return pickerActive; }
/**
* One-shot yes/no question. Returns true only when the user types y/yes
* (case-insensitive). Returns the supplied default when stdin isn't a TTY
* (e.g. piped non-interactive runs).
*/
export function askYesNo(question: string, defaultValue = false): Promise<boolean> {
if (getAmbientChat() && process.stdin.isTTY) {
return runInkYesNo(question, defaultValue);
}
if (!activeReadline || !process.stdin.isTTY) {
return Promise.resolve(defaultValue);
}
return new Promise((resolve) => {
const rl = activeReadline!;
// The parent rl was paused by runAgentTurn; resume so it actually reads
// keystrokes. We re-pause once we have the answer.
rl.resume();
rl.question(question, (answer) => {
const lower = (answer ?? '').trim().toLowerCase();
const yes = lower === 'y' || lower === 'yes';
rl.pause();
resolve(yes);
});
});
}
/**
* Surfaced when `askChoice` is called outside an interactive TTY. The tool
* wrapper turns this into a tool-call error so the LLM falls back to deciding
* itself — silently picking option 1 for the agent in CI / piped runs would
* make a load-bearing decision the user never saw.
*/
export class NoTTYError extends Error {
constructor(message: string) {
super(message);
this.name = 'NoTTYError';
}
}
/**
* Surfaced when the user pressed Esc / q / Ctrl+C inside the picker.
* The tool wrapper converts this into a tool-call error so the LLM knows
* the user declined to commit and can re-plan instead of guessing.
*/
export class CancelledChoiceError extends Error {
constructor(message = 'ask_user_choice was cancelled by the user before they picked an option.') {
super(message);
this.name = 'CancelledChoiceError';
}
}
export interface ChoiceOption {
label: string;
description: string;
}
// --- Pure picker state machine -------------------------------------------
// Split out as exported pure functions so they're trivial to unit-test
// without faking a TTY or piping through keypress events. The orchestrator
// (`askChoice`) only owns the side-effecting bits: wiring stdin keypress
// events into the reducer and re-rendering the screen.
/** Synthetic always-on "Other" option appended to every picker. */
const OTHER_LABEL = 'Other';
const OTHER_DESCRIPTION = 'Type a free-form answer not listed above';
export interface PickerState {
/** Includes the synthetic Other entry at the last index. */
options: ChoiceOption[];
cursor: number;
multiSelect: boolean;
/** Indices of options the user has toggled on (multi-select only). */
selected: Set<number>;
/** True once the user confirmed Other and we're collecting free text. */
awaitingOther: boolean;
/** Accumulated free-text for the Other prompt. */
otherText: string;
done: boolean;
cancelled: boolean;
/** Final resolved value when `done && !cancelled`. */
result: string | string[] | null;
}
/** Normalized keystroke shape the reducer consumes. Decoupled from Node's
* raw `keypress` event so the reducer can be driven by test inputs that
* don't go through `emitKeypressEvents`. */
export interface PickerKey {
name?: string;
ctrl?: boolean;
sequence?: string;
/** A single printable character for free-text capture (Other phase). */
char?: string;
}
/**
* `prefilledOther` drops the picker straight into the free-text "Other"
* phase with the supplied string already in the buffer. Used by the
* 0.3.7 wizard / `/config` panel when a value can be derived from an
* env var — the user sees the env value and presses ENTER to accept or
* edits to override. Pass an empty string to keep today's behaviour.
*
* `initialCursor` lets a picker open with a non-zero highlight so the
* settings home panel can re-open on the row the user just edited
* without re-scrolling them to the top.
*/
export interface InitPickerStateOptions {
prefilledOther?: string;
initialCursor?: number;
}
export function initPickerState(
options: ChoiceOption[],
multiSelect: boolean,
init: InitPickerStateOptions = {},
): PickerState {
const augmented = [...options, { label: OTHER_LABEL, description: OTHER_DESCRIPTION }];
const otherText = init.prefilledOther ?? '';
const awaitingOther = otherText.length > 0;
// When pre-filled "Other" is requested, position the cursor on the
// Other row so a subsequent Esc → re-render lands the user there
// (otherwise they'd snap back to row 0 with no explanation).
const cursor = awaitingOther
? augmented.length - 1
: Math.max(0, Math.min(init.initialCursor ?? 0, augmented.length - 1));
return {
options: augmented,
cursor,
multiSelect,
selected: new Set<number>(),
awaitingOther,
otherText,
done: false,
cancelled: false,
result: null,
};
}
function finalizeWithOther(state: PickerState, text: string): PickerState {
if (state.multiSelect) {
const otherIdx = state.options.length - 1;
const indices = Array.from(state.selected).sort((a, b) => a - b);
const labels = indices.map((i) => (i === otherIdx ? text : state.options[i].label));
return { ...state, done: true, result: labels, otherText: text };
}
return { ...state, done: true, result: text, otherText: text };
}
export function reducePicker(state: PickerState, key: PickerKey): PickerState {
if (state.done) return state;
// Ctrl+C always cancels, in any phase. Don't gate on `key.name === 'c'`
// alone — some terminals send the sequence without a named binding.
if (key.ctrl && (key.name === 'c' || key.sequence === '')) {
return { ...state, done: true, cancelled: true };
}
// --- Free-text "Other" phase ------------------------------------------
if (state.awaitingOther) {
if (key.name === 'return' || key.sequence === '\r' || key.sequence === '\n') {
const text = state.otherText.trim();
if (!text) return state; // empty ENTER is a no-op so the user can retry
return finalizeWithOther(state, text);
}
if (key.name === 'backspace') {
return { ...state, otherText: state.otherText.slice(0, -1) };
}
if (key.name === 'escape') {
// Bail back to the picker so a stray ENTER on Other isn't a one-way trip.
return { ...state, awaitingOther: false, otherText: '' };
}
if (key.char && key.char.length === 1) {
return { ...state, otherText: state.otherText + key.char };
}
return state;
}
// --- Picker phase -----------------------------------------------------
switch (key.name) {
case 'up':
return { ...state, cursor: (state.cursor - 1 + state.options.length) % state.options.length };
case 'down':
return { ...state, cursor: (state.cursor + 1) % state.options.length };
case 'space': {
if (!state.multiSelect) return state;
const next = new Set(state.selected);
if (next.has(state.cursor)) next.delete(state.cursor);
else next.add(state.cursor);
return { ...state, selected: next };
}
case 'return': {
const otherIdx = state.options.length - 1;
if (state.multiSelect) {
// Confirming with nothing selected is a no-op — the user must SPACE
// at least one row first. Bailing here keeps "I pressed ENTER too
// soon" from silently committing to an empty array.
if (state.selected.size === 0) return state;
if (state.selected.has(otherIdx)) {
return { ...state, awaitingOther: true };
}
const indices = Array.from(state.selected).sort((a, b) => a - b);
return { ...state, done: true, result: indices.map((i) => state.options[i].label) };
}
if (state.cursor === otherIdx) {
return { ...state, awaitingOther: true };
}
return { ...state, done: true, result: state.options[state.cursor].label };
}
case 'escape':
case 'q':
return { ...state, done: true, cancelled: true };
}
return state;
}
export function renderPicker(state: PickerState, question: string, header?: string): string {
const lines: string[] = [];
if (header) lines.push(`[${header}]`);
lines.push(question);
lines.push('');
for (let i = 0; i < state.options.length; i++) {
const opt = state.options[i];
const cursor = i === state.cursor ? '▶' : ' ';
const mark = state.multiSelect ? (state.selected.has(i) ? '☑ ' : '☐ ') : '';
lines.push(` ${cursor} ${mark}${opt.label} — ${opt.description}`);
}
lines.push('');
if (state.awaitingOther) {
lines.push('[Other] Type your answer and press ENTER · Backspace to edit · Esc to go back');
lines.push(`> ${state.otherText}_`);
} else if (state.multiSelect) {
lines.push('↑/↓ navigate · SPACE toggle · ENTER confirm · q to cancel');
} else {
lines.push('↑/↓ navigate · ENTER confirm · q to cancel');
}
return lines.join('\n');
}
/**
* Mid-turn multi-choice prompt with arrow-key navigation, a checkbox UI
* for multi-select, and an always-on "Other" option that drops to free-text
* input. Pause/resume the parent REPL the same way `askYesNo` does, so it
* composes cleanly with the existing readline bridge.
*
* Non-TTY behavior is strict: throws `NoTTYError` instead of defaulting to
* option 1. The agent calling this is asking the human for judgment; making
* the call for them in CI / piped / `brainrouter run` would silently commit
* to a path the user never saw.
*
* User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
* tool wrapper can surface "user declined to commit" as a tool-call error.
*/
/**
* 0.3.7 picker opts.
*
* `onCursorChange(index)` fires after every arrow-key move that actually
* moves the cursor (no-op keys, ENTER, SPACE don't fire it). The 0.3.7
* theme picker uses this to live-preview the selected theme by redrawing
* the banner accent before the user confirms — pattern lifted from
*
* `prefilledOther` opens the picker with the synthetic "Other" row
* already selected AND the free-text input pre-filled. Used when a value
* is derived from an env var so the user can press ENTER to accept or
* edit in-place. Pre-fill flips `awaitingOther` true on init.
*
* `initialCursor` lets the settings home panel re-open on the row the
* user just left, avoiding a snap-to-row-0 after every sub-picker.
*/
export interface AskChoiceOptions {
multiSelect?: boolean;
header?: string;
onCursorChange?: (cursor: number) => void;
prefilledOther?: string;
initialCursor?: number;
}
export function askChoice(
question: string,
options: ChoiceOption[],
opts: AskChoiceOptions = {},
): Promise<string | string[]> {
// Input-shape validation first — bad shape is a caller bug regardless of
// TTY availability, and surfacing it as "no TTY" would misdirect the agent
// toward "decide yourself" when the real fix is "re-emit the call with a
// valid options array".
if (!Array.isArray(options) || options.length < 2 || options.length > 4) {
const count = Array.isArray(options) ? options.length : 'invalid';
return Promise.reject(
new Error(`ask_user_choice requires 2–4 options; received ${count}.`),
);
}
// Reject duplicate labels (case-insensitive). The picker shows labels as
// the human-readable identifier and returns them as the result, so two
// options with the same label make the return value ambiguous and downstream
// branching unreliable. Catch it here, not after the picker is half-drawn.
// The synthetic "Other" option also collides with a user-supplied "other",
// so reject that too.
const seen = new Set<string>();
for (const o of options) {
const key = (o?.label ?? '').toLowerCase();
if (key === OTHER_LABEL.toLowerCase()) {
return Promise.reject(
new Error(`ask_user_choice cannot use "${o.label}" as a label — "${OTHER_LABEL}" is reserved for the always-on free-text fallback.`),
);
}
if (seen.has(key)) {
return Promise.reject(
new Error(`ask_user_choice options must have unique labels; "${o.label}" appears more than once (case-insensitive).`),
);
}
seen.add(key);
}
if (!activeReadline || !process.stdin.isTTY) {
return Promise.reject(
new NoTTYError(
'ask_user_choice requires an interactive TTY (no readline interface is active or stdin is not a TTY). ' +
'Fall back to deciding yourself based on the available context, and state which option you picked and why in your reply.',
),
);
}
if (getAmbientChat()) {
return runInkChoice(question, options, opts);
}
return runPicker(question, options, opts);
}
async function runInkYesNo(question: string, defaultValue: boolean): Promise<boolean> {
const { runPicker } = await import('./ink/runPicker.js');
const result = await runPicker({
title: question,
badge: 'Confirm',
rows: [
{ id: 'yes', label: 'Yes', description: 'Allow this action' },
{ id: 'no', label: 'No', description: 'Do not allow this action' },
],
initialCursor: defaultValue ? 0 : 1,
allowOther: false,
});
if (result.kind !== 'pick') return defaultValue;
return result.id === 'yes';
}
async function runInkChoice(
question: string,
options: ChoiceOption[],
opts: AskChoiceOptions,
): Promise<string | string[]> {
const { runPicker } = await import('./ink/runPicker.js');
const rows = options.map((option, i) => ({
id: `choice:${i}`,
label: option.label,
description: option.description,
}));
const result = await runPicker({
title: question,
badge: opts.header,
rows,
initialCursor: opts.initialCursor,
allowOther: true,
otherLabel: OTHER_LABEL,
otherDescription: OTHER_DESCRIPTION,
prefilledOther: opts.prefilledOther,
multiSelect: !!opts.multiSelect,
onCursorChange: opts.onCursorChange
? (_id: string, index: number) => {
opts.onCursorChange?.(index);
return undefined;
}
: undefined,
});
if (result.kind === 'cancelled') {
throw new CancelledChoiceError();
}
if (result.kind === 'other') {
return result.text;
}
if (result.kind === 'multi') {
const answers = result.ids.map((id) => {
const idx = Number(id.slice('choice:'.length));
return options[idx]?.label;
}).filter((label): label is string => !!label);
if (result.otherText) answers.push(result.otherText);
return answers;
}
const idx = Number(result.id.slice('choice:'.length));
return options[idx]?.label ?? options[0].label;
}
function runPicker(
question: string,
options: ChoiceOption[],
opts: AskChoiceOptions,
): Promise<string | string[]> {
return new Promise((resolve, reject) => {
const rl = activeReadline!;
const stdout = process.stdout;
let state = initPickerState(options, !!opts.multiSelect, {
prefilledOther: opts.prefilledOther,
initialCursor: opts.initialCursor,
});
let renderedNewlines = 0;
let renderedAtLeastOnce = false;
// Pause the parent rl so its `line` handler doesn't fire on our ENTER
// press. We restore on cleanup.
rl.pause();
// readline.createInterface already calls emitKeypressEvents and sets raw
// mode for a TTY input; this is belt-and-suspenders for cases where the
// parent code disabled raw mode somewhere along the way.
readline.emitKeypressEvents(process.stdin);
try { (process.stdin as any).setRawMode?.(true); } catch { /* not a real TTY */ }
process.stdin.resume();
// Hide cursor while the picker is on screen — keeps the rendering tight.
stdout.write('\x1b[?25l');
pickerActive = true;
const clear = () => {
if (renderedNewlines > 0) {
// `\x1b[<n>F` = cursor up n lines AND col 1 (atomic). For an
// M-line frame containing M-1 newlines, the cursor sits at
// the END of line M after the write (we don't write a
// trailing newline). Going up `renderedNewlines` (= M-1)
// lines lands EXACTLY at the start of line 1 — no off-by-one.
stdout.write(`\x1b[${renderedNewlines}F\x1b[J`);
} else if (renderedNewlines === 0 && renderedAtLeastOnce) {
// Single-line frame edge case: nothing to scroll up; just
// wipe the current line.
stdout.write('\r\x1b[K');
}
};
const render = () => {
clear();
const text = renderPicker(state, question, opts.header);
stdout.write(text);
// Track newlines (NOT lines). For "a\nb\nc" that's 2 — which
// is exactly the cursor-up count we need for the next clear().
renderedNewlines = (text.match(/\n/g) ?? []).length;
renderedAtLeastOnce = true;
};
const cleanup = () => {
process.stdin.removeListener('keypress', onKeypress);
// Restore cursor visibility. Leave raw mode TRUE — the REPL expects it
// on (Backspace + arrow keys + readline's editing all rely on raw mode)
// and a previous version that restored a captured `wasRaw` flipped raw
// mode back to false in terminals where readline's auto-init never
// fully engaged, which manifested as Backspace echoing `^?` after the
// picker exited. Picker is the one component that's GUARANTEED to know
// raw mode is needed, so it's the right place to assert the invariant.
stdout.write('\x1b[?25h');
try { (process.stdin as any).setRawMode?.(true); } catch { /* noop */ }
pickerActive = false;
// Don't auto-resume the parent rl — runAgentTurn paused it intentionally
// and will resume on its own schedule.
};
const onKeypress = (str: string | undefined, key: any) => {
const named = key?.name;
const isPrintable = typeof str === 'string'
&& str.length === 1
&& !key?.ctrl
&& named !== 'return'
&& named !== 'escape'
&& named !== 'backspace'
&& named !== 'tab';
const pk: PickerKey = {
name: named,
ctrl: !!key?.ctrl,
sequence: key?.sequence,
char: isPrintable ? str : undefined,
};
const prevCursor = state.cursor;
const wasAwaitingOther = state.awaitingOther;
const nextState = reducePicker(state, pk);
if (nextState === state) return;
state = nextState;
// Live-preview hook (0.3.7): fire on a genuine cursor move in the
// picker phase only. Don't fire while collecting free-text in the
// "Other" phase — that would spam the callback on every keystroke
// for no useful signal. Settling back into picker phase from Other
// (Esc) doesn't fire either (the cursor "stayed" on Other).
if (
opts.onCursorChange
&& !state.done
&& !state.awaitingOther
&& !wasAwaitingOther
&& state.cursor !== prevCursor
) {
try { opts.onCursorChange(state.cursor); } catch { /* preview callbacks must never crash the picker */ }
}
render();
if (state.done) {
cleanup();
if (state.cancelled) {
reject(new CancelledChoiceError());
} else {
resolve(state.result!);
}
}
};
process.stdin.on('keypress', onKeypress);
render();
});
}
/**
* Print a line of output while the prompt is showing, then redraw the prompt
* with whatever the user was mid-typing. Used by callbacks that fire while the
* REPL is idle (child agents that complete async after the parent turn ended).
*/
export function safePrintAbovePrompt(msg: string): void {
if (!process.stdout.isTTY || !activeReadline) {
console.log(msg);
return;
}
process.stdout.write('\r\x1b[2K');
console.log(msg);
try { (activeReadline as any)._refreshLine?.(); } catch { activeReadline.prompt(true); }
}