flashlite 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.
@@ -0,0 +1,982 @@
1
+ // lineinput.ts — raw-mode line editor for the REPL (macOS Terminal).
2
+ // Bracketed paste submits multi-line pastes as one turn; Ctrl+C interrupts, Ctrl+D
3
+ // exits, arrows walk history, Ctrl+A/E and left/right move the cursor. Piped stdin
4
+ // falls back to plain readline so `echo "…" | flash` keeps working.
5
+ import readline from 'node:readline';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { ui, orange, bold, dim } from "./ui.js";
8
+ import { matchingCommands } from "./commands.js";
9
+ const PASTE_START = '\x1b[200~';
10
+ const PASTE_END = '\x1b[201~';
11
+ /** Visible rows in the picker menu; the list scrolls under this window when longer. */
12
+ const MENU_HEIGHT = 15;
13
+ /** Pure key-to-action core — separated so the editor logic stays testable. */
14
+ export function editLine(chars, state) {
15
+ const out = { ...state };
16
+ for (const c of chars) {
17
+ if (c === '\r' || c === '\n') {
18
+ return { ...out, submit: out.buffer };
19
+ }
20
+ if (c === '\x03') {
21
+ return { buffer: '', cursor: 0, interrupt: true };
22
+ }
23
+ if (c === '\x7f' || c === '\b') {
24
+ if (out.cursor > 0) {
25
+ out.buffer = out.buffer.slice(0, out.cursor - 1) + out.buffer.slice(out.cursor);
26
+ out.cursor--;
27
+ }
28
+ continue;
29
+ }
30
+ if (c === '\x01') {
31
+ out.cursor = 0; // Ctrl+A home
32
+ continue;
33
+ }
34
+ if (c === '\x05') {
35
+ out.cursor = out.buffer.length; // Ctrl+E end
36
+ continue;
37
+ }
38
+ if (c === '\x15') {
39
+ out.buffer = ''; // Ctrl+U kill line
40
+ out.cursor = 0;
41
+ continue;
42
+ }
43
+ if (c === '\x0b') {
44
+ out.buffer = out.buffer.slice(0, out.cursor); // Ctrl+K kill to end
45
+ continue;
46
+ }
47
+ if (c >= ' ') {
48
+ out.buffer = out.buffer.slice(0, out.cursor) + c + out.buffer.slice(out.cursor);
49
+ out.cursor++;
50
+ }
51
+ // other control chars are ignored
52
+ }
53
+ return out;
54
+ }
55
+ export class LineInput {
56
+ handlers;
57
+ prompt = orange('❯ ');
58
+ model = '';
59
+ buffer = '';
60
+ cursor = 0;
61
+ history = [];
62
+ histIndex = 0;
63
+ historyDraft = null;
64
+ pasting = false;
65
+ pasteBuf = '';
66
+ esc = '';
67
+ decoder = new StringDecoder('utf8');
68
+ fallback = null;
69
+ questionResolve = null;
70
+ echoQuestion = true;
71
+ maskQuestion = false;
72
+ stopped = false;
73
+ paused = false;
74
+ working = false;
75
+ questionDraft = null;
76
+ selectDraft = null;
77
+ selectItems = null;
78
+ selectIndex = 0;
79
+ selectTop = 0;
80
+ selectResolve = null;
81
+ selectEsc = '';
82
+ menuShown = false;
83
+ dockMenu = false;
84
+ selectEscapeTimer = null;
85
+ paletteOpen = false;
86
+ paletteIndex = 0;
87
+ paletteText = '';
88
+ paletteDismissed = false;
89
+ viewResolve = null;
90
+ viewRefresh = null;
91
+ viewEsc = '';
92
+ viewRefreshing = false;
93
+ completer = null;
94
+ navigation = '';
95
+ navigationTimer = null;
96
+ // Bash-style reverse history search: Ctrl+R opens it, ✔→older/↑→older, ↓→newer,
97
+ // Enter accepts the match into the draft, Esc restores the pre-search draft.
98
+ searching = false;
99
+ searchQuery = '';
100
+ searchMatches = [];
101
+ searchPos = 0;
102
+ searchDraft = null;
103
+ /** Live read-only command panel; arrows scroll, r refreshes, Enter/Esc dismisses. */
104
+ view(lines, refresh) {
105
+ this.closePalette();
106
+ if (!ui.panel(lines, () => this.closeView())) {
107
+ lines.forEach((line) => ui.say(line));
108
+ return Promise.resolve();
109
+ }
110
+ this.viewRefresh = refresh;
111
+ return new Promise((resolve) => { this.viewResolve = resolve; });
112
+ }
113
+ updateView(lines) {
114
+ if (this.viewResolve)
115
+ ui.panel(lines);
116
+ }
117
+ closeView() {
118
+ const resolve = this.viewResolve;
119
+ this.viewResolve = null;
120
+ this.viewRefresh = null;
121
+ this.viewEsc = '';
122
+ this.viewRefreshing = false;
123
+ if (this.selectEscapeTimer)
124
+ clearTimeout(this.selectEscapeTimer);
125
+ this.selectEscapeTimer = null;
126
+ ui.closePanel();
127
+ resolve?.();
128
+ }
129
+ closePalette() {
130
+ if (this.paletteOpen)
131
+ ui.closeMenu();
132
+ this.paletteOpen = false;
133
+ if (this.selectEscapeTimer)
134
+ clearTimeout(this.selectEscapeTimer);
135
+ this.selectEscapeTimer = null;
136
+ this.esc = '';
137
+ }
138
+ constructor(handlers) {
139
+ this.handlers = handlers;
140
+ }
141
+ /** Enable the editor; piped stdin falls back to plain readline. */
142
+ start() {
143
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
144
+ this.fallback = readline.createInterface({ input: process.stdin, output: process.stdout });
145
+ this.fallback.on('line', (line) => {
146
+ if (this.questionResolve) {
147
+ this.answerQuestion(line);
148
+ return;
149
+ }
150
+ this.handlers.onLine(line);
151
+ });
152
+ this.fallback.on('SIGINT', () => {
153
+ if (this.questionResolve)
154
+ this.answerQuestion('n');
155
+ this.handlers.onInterrupt();
156
+ });
157
+ this.fallback.on('close', () => {
158
+ if (this.questionResolve)
159
+ this.answerQuestion('n');
160
+ this.handlers.onEof();
161
+ });
162
+ return;
163
+ }
164
+ process.stdin.setRawMode(true);
165
+ process.stdin.resume();
166
+ process.stdin.on('data', this.onData);
167
+ process.on('SIGTERM', this.onSigterm);
168
+ this.redraw();
169
+ }
170
+ stop() {
171
+ if (this.stopped)
172
+ return;
173
+ this.stopped = true;
174
+ if (this.navigationTimer)
175
+ clearTimeout(this.navigationTimer);
176
+ this.navigationTimer = null;
177
+ this.navigation = '';
178
+ if (this.searching)
179
+ this.searchCancel();
180
+ this.closePalette();
181
+ if (this.viewResolve)
182
+ this.closeView();
183
+ ui.clearComposer();
184
+ if (this.selectResolve)
185
+ this.selectFinish(null);
186
+ ui.closeDock();
187
+ if (this.fallback) {
188
+ this.fallback.close();
189
+ return;
190
+ }
191
+ process.stdin.off('data', this.onData);
192
+ process.off('SIGTERM', this.onSigterm);
193
+ if (process.stdin.isTTY)
194
+ process.stdin.setRawMode(false);
195
+ process.stdin.pause();
196
+ }
197
+ /** Stop and fire onEof exactly once (the /exit and Ctrl+D path). */
198
+ close() {
199
+ if (this.stopped)
200
+ return;
201
+ if (this.fallback) {
202
+ this.fallback.close(); // its 'close' handler fires onEof
203
+ return;
204
+ }
205
+ this.stop();
206
+ this.handlers.onEof();
207
+ }
208
+ /** Pause normal editing during command/clipboard loading; interrupts stay responsive. */
209
+ pause() {
210
+ this.paused = true;
211
+ ui.clearComposer();
212
+ }
213
+ resume() {
214
+ this.paused = false;
215
+ this.redraw();
216
+ }
217
+ /** Live drafting during agent turns; submitted steering renders at a safe boundary. */
218
+ setWorking(working) {
219
+ this.working = working;
220
+ this.redraw();
221
+ }
222
+ /** Release an open command panel/picker before an agent permission question. */
223
+ cancelControls() {
224
+ if (this.searching)
225
+ this.searchCancel();
226
+ if (this.viewResolve)
227
+ this.closeView();
228
+ if (this.selectResolve)
229
+ this.selectFinish(null);
230
+ if (this.questionResolve)
231
+ this.answerQuestion('n');
232
+ this.closePalette();
233
+ }
234
+ /** Put unsent steering back in the editor after cancellation/error/round limit. */
235
+ restoreDraft(lines) {
236
+ if (!lines.length)
237
+ return;
238
+ const prefix = lines.join(' ') + (this.buffer ? ' ' : '');
239
+ this.buffer = prefix + this.buffer;
240
+ this.cursor += prefix.length;
241
+ this.redraw();
242
+ }
243
+ answerQuestion(answer) {
244
+ const resolve = this.questionResolve;
245
+ this.questionResolve = null;
246
+ ui.closeApproval();
247
+ this.echoQuestion = true;
248
+ this.maskQuestion = false;
249
+ if (this.questionDraft) {
250
+ const draft = this.questionDraft;
251
+ this.buffer = draft.buffer;
252
+ this.cursor = draft.cursor;
253
+ this.histIndex = draft.histIndex;
254
+ this.prompt = draft.prompt;
255
+ this.questionDraft = null;
256
+ }
257
+ this.redraw();
258
+ resolve?.(answer);
259
+ }
260
+ /** One-shot question mode temporarily replaces, then restores, the current draft. */
261
+ question(prompt, echoAnswer = true, maskInput = false) {
262
+ if (maskInput && this.fallback)
263
+ return Promise.resolve('');
264
+ this.questionDraft = { buffer: this.buffer, cursor: this.cursor, histIndex: this.histIndex, prompt: this.prompt };
265
+ this.echoQuestion = echoAnswer;
266
+ this.maskQuestion = maskInput;
267
+ this.prompt = prompt;
268
+ this.buffer = '';
269
+ this.cursor = 0;
270
+ return new Promise((resolve) => {
271
+ this.questionResolve = resolve;
272
+ this.redraw();
273
+ });
274
+ }
275
+ /** Interactive picker: arrow-key menu in a TTY, numbered list in a pipe. Resolves to
276
+ * the chosen index (0-based) or null when cancelled. `current` pre-highlights a row. */
277
+ select(items, current = 0) {
278
+ if (items.length === 0)
279
+ return Promise.resolve(null);
280
+ if (this.fallback) {
281
+ const width = String(items.length).length;
282
+ items.forEach((label, i) => ui.say(`${String(i + 1).padStart(width)}. ${label}`));
283
+ return this.question('pick a number (Enter to cancel): ').then((answer) => {
284
+ const n = Number(answer);
285
+ return Number.isInteger(n) && n >= 1 && n <= items.length ? n - 1 : null;
286
+ });
287
+ }
288
+ this.closePalette();
289
+ this.selectDraft = { buffer: this.buffer, cursor: this.cursor, histIndex: this.histIndex };
290
+ this.selectItems = items;
291
+ this.selectIndex = Math.max(0, Math.min(current, items.length - 1));
292
+ this.selectTop = 0;
293
+ this.selectEsc = '';
294
+ ui.clearLine(); // the menu replaces the prompt line
295
+ this.menuShown = false;
296
+ this.renderMenu();
297
+ return new Promise((resolve) => {
298
+ this.selectResolve = resolve;
299
+ });
300
+ }
301
+ setModel(model) {
302
+ this.model = model;
303
+ }
304
+ /** Optional path/mention completer invoked on Tab; returns new buffer state or null. */
305
+ setCompleter(completer) {
306
+ this.completer = completer;
307
+ }
308
+ /** Re-render prompt + buffer (called after output lands on the terminal). */
309
+ redraw() {
310
+ if (this.stopped || this.paused || this.fallback || this.selectItems || this.viewResolve)
311
+ return;
312
+ if (this.questionResolve) {
313
+ const shown = this.maskQuestion ? '*'.repeat([...this.buffer].length) : this.buffer;
314
+ if (!ui.setApprovalChoice(shown, Math.min(this.cursor, shown.length)))
315
+ ui.line(this.prompt, shown, Math.min(this.cursor, shown.length));
316
+ }
317
+ else {
318
+ if (this.buffer !== this.paletteText) {
319
+ this.paletteText = this.buffer;
320
+ this.paletteIndex = 0;
321
+ this.paletteDismissed = false;
322
+ }
323
+ const commands = this.searching || this.paletteDismissed ? [] : matchingCommands(this.buffer);
324
+ if (commands.length) {
325
+ this.paletteIndex = Math.min(this.paletteIndex, commands.length - 1);
326
+ this.paletteOpen = ui.menu(commands.map((c) => `${c.id.padEnd(9)} ${c.description}`), this.paletteIndex, 0, () => { this.paletteDismissed = true; this.closePalette(); }, '↑/↓ choose · tab complete · enter run · esc close');
327
+ }
328
+ else
329
+ this.closePalette();
330
+ ui.composer(this.model, this.prompt, this.buffer, this.cursor);
331
+ }
332
+ }
333
+ setLine(text) {
334
+ this.buffer = text;
335
+ this.cursor = text.length;
336
+ }
337
+ submit(text) {
338
+ this.closePalette();
339
+ if (this.working && !this.questionResolve)
340
+ ui.clearComposer(); // queued steering renders at the next safe boundary
341
+ else if (text.trimStart().startsWith('/') && !this.questionResolve && process.stdout.isTTY)
342
+ ui.clearComposer();
343
+ else if ((!this.questionResolve || this.echoQuestion) && !ui.hasApproval())
344
+ ui.submitInput(this.prompt, text);
345
+ this.buffer = '';
346
+ this.cursor = 0;
347
+ this.prompt = orange('❯ ');
348
+ if (this.questionResolve) {
349
+ this.answerQuestion(text);
350
+ return;
351
+ }
352
+ if (text !== '' && this.history.at(-1) !== text) {
353
+ this.history.push(text);
354
+ }
355
+ this.histIndex = this.history.length;
356
+ this.historyDraft = null;
357
+ this.handlers.onLine(text);
358
+ }
359
+ onData = (chunk) => {
360
+ for (const c of this.decoder.write(chunk)) {
361
+ if (this.stopped)
362
+ return;
363
+ if (this.pasting) {
364
+ this.pasteBuf += c;
365
+ if (this.pasteBuf.endsWith(PASTE_END)) {
366
+ this.pasteBuf = this.pasteBuf.slice(0, -PASTE_END.length);
367
+ this.deliver(PASTE_END);
368
+ }
369
+ continue;
370
+ }
371
+ if (c === '\x03' || c === '\x04') {
372
+ if (this.navigationTimer)
373
+ clearTimeout(this.navigationTimer);
374
+ this.navigationTimer = null;
375
+ this.navigation = '';
376
+ this.deliver(c);
377
+ continue;
378
+ }
379
+ if (this.navigation || c === '\x1b') {
380
+ this.navigation += c;
381
+ if (this.navigationTimer)
382
+ clearTimeout(this.navigationTimer);
383
+ this.navigationTimer = null;
384
+ if (/^\x1b\[[\d;<?]*[A-Za-z~]$/.test(this.navigation) || this.navigation.length > 80 ||
385
+ this.navigation.length > 1 && !this.navigation.startsWith('\x1b[')) {
386
+ const sequence = this.navigation;
387
+ this.navigation = '';
388
+ if (!this.scrollKey(sequence))
389
+ this.deliver(sequence);
390
+ }
391
+ else {
392
+ this.navigationTimer = setTimeout(() => {
393
+ const sequence = this.navigation;
394
+ this.navigation = '';
395
+ this.navigationTimer = null;
396
+ if (sequence === '\x1b' && !this.paused) {
397
+ if (this.viewResolve)
398
+ this.closeView();
399
+ else if (this.selectResolve)
400
+ this.selectFinish(null);
401
+ else if (this.questionResolve)
402
+ this.answerQuestion('n');
403
+ else if (this.searching)
404
+ this.searchCancel();
405
+ else {
406
+ this.paletteDismissed = true;
407
+ this.closePalette();
408
+ this.redraw();
409
+ }
410
+ }
411
+ }, 100);
412
+ }
413
+ continue;
414
+ }
415
+ this.deliver(c);
416
+ }
417
+ };
418
+ deliver(chars) {
419
+ if (this.stopped)
420
+ return;
421
+ if (this.paused) {
422
+ if (chars.includes('\x04'))
423
+ this.close();
424
+ else if (chars.includes('\x03'))
425
+ this.handlers.onInterrupt();
426
+ }
427
+ else
428
+ this.handle(chars);
429
+ }
430
+ scrollKey(sequence) {
431
+ const mouse = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(sequence);
432
+ const page = sequence === '\x1b[5~' ? -1 : sequence === '\x1b[6~' ? 1 : 0;
433
+ if (!mouse && !page)
434
+ return false;
435
+ const button = mouse ? Number(mouse[1]) : 0;
436
+ if (mouse && (button & 64) === 0)
437
+ return true; // discard click reports
438
+ const direction = page || ((button & 1) === 0 ? -1 : 1);
439
+ const delta = direction * (page ? 8 : 3);
440
+ if (this.viewResolve)
441
+ ui.scrollPanel(delta);
442
+ else if (this.selectItems)
443
+ this.selectMove(delta);
444
+ else if (this.paletteOpen) {
445
+ this.paletteIndex = Math.max(0, Math.min(matchingCommands(this.buffer).length - 1, this.paletteIndex + direction));
446
+ this.redraw();
447
+ }
448
+ else if (this.questionResolve && ui.hasApproval())
449
+ ui.scrollApproval(delta);
450
+ else
451
+ ui.scrollChat(-delta);
452
+ return true;
453
+ }
454
+ onSigterm = () => {
455
+ this.stop();
456
+ process.exit(0);
457
+ };
458
+ handle(chars) {
459
+ if (this.viewResolve) {
460
+ this.handleView(chars);
461
+ return;
462
+ }
463
+ if (this.selectResolve) {
464
+ this.handleSelect(chars);
465
+ return;
466
+ }
467
+ let i = 0;
468
+ while (i < chars.length) {
469
+ const c = chars[i];
470
+ if (this.searching) {
471
+ i = this.handleSearch(chars, i);
472
+ continue;
473
+ }
474
+ if (this.pasting) {
475
+ const end = chars.indexOf(PASTE_END, i);
476
+ if (end === -1) {
477
+ this.pasteBuf += chars.slice(i);
478
+ return;
479
+ }
480
+ this.pasteBuf += chars.slice(i, end);
481
+ i = end + PASTE_END.length;
482
+ this.pasting = false;
483
+ const pasted = this.pasteBuf;
484
+ this.pasteBuf = '';
485
+ if (pasted.includes('\n')) {
486
+ if (pasted.trimEnd() !== '')
487
+ this.setLine(pasted.replace(/\n/g, ' ').trimEnd());
488
+ // A trailing-newline paste submits as one turn (bracketed-paste contract).
489
+ if (pasted.endsWith('\n')) {
490
+ this.submit(this.buffer);
491
+ this.redraw();
492
+ return;
493
+ }
494
+ this.redraw();
495
+ continue;
496
+ }
497
+ this.insert(pasted);
498
+ this.redraw();
499
+ continue;
500
+ }
501
+ if (this.esc) {
502
+ if (this.selectEscapeTimer)
503
+ clearTimeout(this.selectEscapeTimer);
504
+ this.selectEscapeTimer = null;
505
+ this.esc += c;
506
+ if (this.escIsComplete()) {
507
+ this.handleEscape(this.esc);
508
+ this.esc = '';
509
+ }
510
+ i++;
511
+ continue;
512
+ }
513
+ if (c === '\x1b') {
514
+ this.esc = c;
515
+ if (this.paletteOpen) {
516
+ this.selectEscapeTimer = setTimeout(() => {
517
+ this.esc = '';
518
+ this.paletteDismissed = true;
519
+ this.closePalette();
520
+ this.redraw();
521
+ }, 100);
522
+ }
523
+ i++;
524
+ continue;
525
+ }
526
+ if (!this.questionResolve && c === '\x0f') { // Ctrl+O: expand/collapse the latest tool result
527
+ ui.toggleToolDetails();
528
+ i++;
529
+ continue;
530
+ }
531
+ if (!this.questionResolve && (c === '\x16' || c === '\x18')) {
532
+ this.closePalette();
533
+ this.paletteDismissed = true;
534
+ if (c === '\x16')
535
+ this.handlers.onPasteImage?.();
536
+ else
537
+ this.handlers.onClearImages?.();
538
+ i++;
539
+ continue;
540
+ }
541
+ if (c === '\t') {
542
+ if (this.paletteOpen) {
543
+ const chosen = matchingCommands(this.buffer)[this.paletteIndex];
544
+ if (chosen)
545
+ this.setLine(chosen.id + ' ');
546
+ this.redraw();
547
+ i++;
548
+ continue;
549
+ }
550
+ if (this.completer) {
551
+ const next = this.completer(this.buffer, this.cursor);
552
+ if (next) {
553
+ this.buffer = next.buffer;
554
+ this.cursor = next.cursor;
555
+ this.redraw();
556
+ }
557
+ }
558
+ i++;
559
+ continue;
560
+ }
561
+ if (c === '\r' || c === '\n') {
562
+ const chosen = this.paletteOpen ? matchingCommands(this.buffer)[this.paletteIndex] : undefined;
563
+ this.submit(chosen?.id ?? this.buffer);
564
+ i++;
565
+ continue;
566
+ }
567
+ if (c === '\x03') {
568
+ if (!this.working || this.questionResolve)
569
+ this.setLine('');
570
+ this.redraw();
571
+ if (this.questionResolve)
572
+ this.answerQuestion('n'); // cancellation must never approve a write
573
+ this.handlers.onInterrupt();
574
+ i++;
575
+ continue;
576
+ }
577
+ if (c === '\x12' && !this.questionResolve) {
578
+ if (!this.searching && this.history.length > 0) {
579
+ this.closePalette();
580
+ this.searchStart();
581
+ }
582
+ this.redraw();
583
+ i++;
584
+ continue;
585
+ }
586
+ if (c === '\x04') {
587
+ if (this.buffer === '' || this.questionResolve) {
588
+ if (this.questionResolve)
589
+ this.answerQuestion('n');
590
+ this.close();
591
+ }
592
+ i++;
593
+ continue;
594
+ }
595
+ const result = editLine(c, { buffer: this.buffer, cursor: this.cursor });
596
+ this.buffer = result.buffer;
597
+ this.cursor = result.cursor;
598
+ if (result.submit !== undefined)
599
+ this.submit(result.submit);
600
+ else
601
+ this.redraw();
602
+ i++;
603
+ }
604
+ }
605
+ insert(text) {
606
+ this.buffer = this.buffer.slice(0, this.cursor) + text + this.buffer.slice(this.cursor);
607
+ this.cursor += text.length;
608
+ }
609
+ escIsComplete() {
610
+ const e = this.esc;
611
+ if (e === PASTE_START || e === PASTE_END)
612
+ return true;
613
+ if (!e.startsWith('\x1b['))
614
+ return e.length >= 2;
615
+ const last = e.at(-1);
616
+ return last === '~' || last === 'A' || last === 'B' || last === 'C' || last === 'D' || last === 'H' || last === 'F' || last === 'Z';
617
+ }
618
+ handleEscape(seq) {
619
+ if (this.paletteOpen && (seq === '\x1b[A' || seq === '\x1b[B')) {
620
+ const delta = seq === '\x1b[A' ? -1 : 1;
621
+ this.paletteIndex = Math.max(0, Math.min(matchingCommands(this.buffer).length - 1, this.paletteIndex + delta));
622
+ this.redraw();
623
+ return;
624
+ }
625
+ if (seq === PASTE_START) {
626
+ this.pasting = true;
627
+ this.pasteBuf = '';
628
+ return;
629
+ }
630
+ if (seq === PASTE_END) {
631
+ this.pasting = false;
632
+ return;
633
+ }
634
+ switch (seq) {
635
+ case '\x1b[Z': // shift+tab — cycle the permission mode
636
+ if (!this.questionResolve)
637
+ this.handlers.onCycleMode?.();
638
+ break;
639
+ case '\x1b[1;4Z': // shift+alt+tab (xterm modifier 4)
640
+ if (!this.questionResolve)
641
+ this.handlers.onToggleBoost?.();
642
+ break;
643
+ case '\x1b[A': // up — history
644
+ if (!this.questionResolve && this.history.length > 0) {
645
+ if (this.histIndex === this.history.length)
646
+ this.historyDraft = { buffer: this.buffer, cursor: this.cursor };
647
+ this.histIndex = Math.max(0, this.histIndex - 1);
648
+ this.setLine(this.history[this.histIndex] ?? '');
649
+ this.redraw();
650
+ }
651
+ break;
652
+ case '\x1b[B': // down — history
653
+ if (!this.questionResolve && this.histIndex < this.history.length) {
654
+ this.histIndex = Math.min(this.history.length, this.histIndex + 1);
655
+ if (this.histIndex === this.history.length && this.historyDraft) {
656
+ this.buffer = this.historyDraft.buffer;
657
+ this.cursor = this.historyDraft.cursor;
658
+ this.historyDraft = null;
659
+ }
660
+ else
661
+ this.setLine(this.history[this.histIndex] ?? '');
662
+ this.redraw();
663
+ }
664
+ break;
665
+ case '\x1b[C': // right
666
+ if (this.cursor < this.buffer.length) {
667
+ this.cursor++;
668
+ this.redraw();
669
+ }
670
+ break;
671
+ case '\x1b[D': // left
672
+ if (this.cursor > 0) {
673
+ this.cursor--;
674
+ this.redraw();
675
+ }
676
+ break;
677
+ case '\x1b[H': // home
678
+ this.cursor = 0;
679
+ this.redraw();
680
+ break;
681
+ case '\x1b[F': // end
682
+ this.cursor = this.buffer.length;
683
+ this.redraw();
684
+ break;
685
+ default:
686
+ break; // unknown sequences ignored
687
+ }
688
+ }
689
+ handleView(chars) {
690
+ for (const c of chars) {
691
+ if (this.viewEsc) {
692
+ if (this.selectEscapeTimer)
693
+ clearTimeout(this.selectEscapeTimer);
694
+ this.selectEscapeTimer = null;
695
+ this.viewEsc += c;
696
+ if (this.viewEsc === '\x1b[A' || this.viewEsc === '\x1b[B') {
697
+ ui.scrollPanel(this.viewEsc === '\x1b[A' ? -1 : 1);
698
+ this.viewEsc = '';
699
+ }
700
+ else if (!this.viewEsc.startsWith('\x1b[') || this.viewEsc.length > 3)
701
+ this.viewEsc = '';
702
+ continue;
703
+ }
704
+ if (c === '\x1b') {
705
+ this.viewEsc = c;
706
+ this.selectEscapeTimer = setTimeout(() => this.closeView(), 100);
707
+ }
708
+ else if (c === '\r' || c === '\n' || c === '\x03') {
709
+ this.closeView();
710
+ return;
711
+ }
712
+ else if (c === '\x04') {
713
+ this.close();
714
+ return;
715
+ }
716
+ else if (c.toLowerCase() === 'r' && this.viewRefresh && !this.viewRefreshing) {
717
+ this.viewRefreshing = true;
718
+ const view = this.viewResolve;
719
+ void this.viewRefresh()
720
+ .then((lines) => { if (this.viewResolve === view)
721
+ this.updateView(lines); })
722
+ .catch(() => { if (this.viewResolve === view)
723
+ this.updateView(['Usage unavailable — r to retry']); })
724
+ .finally(() => { if (this.viewResolve === view)
725
+ this.viewRefreshing = false; });
726
+ }
727
+ }
728
+ }
729
+ /** Select-mode keys: ↑/↓ (and j/k) move, Enter confirms, Ctrl+C cancels. */
730
+ handleSelect(chars) {
731
+ for (const c of chars) {
732
+ if (this.selectEsc) {
733
+ if (this.selectEscapeTimer)
734
+ clearTimeout(this.selectEscapeTimer);
735
+ this.selectEscapeTimer = null;
736
+ this.selectEsc += c;
737
+ if (this.selectEsc === '\x1b[A') {
738
+ this.selectMove(-1);
739
+ this.selectEsc = '';
740
+ }
741
+ else if (this.selectEsc === '\x1b[B') {
742
+ this.selectMove(1);
743
+ this.selectEsc = '';
744
+ }
745
+ else if (!this.selectEsc.startsWith('\x1b[') || this.selectEsc.length > 3) {
746
+ this.selectEsc = ''; // stray escape — ignore
747
+ }
748
+ continue;
749
+ }
750
+ if (c === '\x1b') {
751
+ this.selectEsc = c;
752
+ this.selectEscapeTimer = setTimeout(() => this.selectFinish(null), 100);
753
+ continue;
754
+ }
755
+ if (c === '\r' || c === '\n') {
756
+ this.selectFinish(this.selectIndex);
757
+ return;
758
+ }
759
+ if (c === '\x03' || c === '\x04') {
760
+ this.selectFinish(null);
761
+ return;
762
+ }
763
+ if (c === 'j' || c === 'J')
764
+ this.selectMove(1);
765
+ else if (c === 'k' || c === 'K')
766
+ this.selectMove(-1);
767
+ // other keys are ignored while the menu is open
768
+ }
769
+ }
770
+ selectMove(delta) {
771
+ if (this.selectItems === null)
772
+ return;
773
+ const n = this.selectItems.length;
774
+ this.selectIndex = Math.max(0, Math.min(n - 1, this.selectIndex + delta));
775
+ this.renderMenu();
776
+ }
777
+ selectFinish(result) {
778
+ const resolve = this.selectResolve;
779
+ const n = this.selectItems?.length ?? 0;
780
+ this.selectResolve = null;
781
+ this.selectItems = null;
782
+ this.selectEsc = '';
783
+ this.selectIndex = 0;
784
+ this.selectTop = 0;
785
+ if (this.selectEscapeTimer)
786
+ clearTimeout(this.selectEscapeTimer);
787
+ this.selectEscapeTimer = null;
788
+ if (this.dockMenu)
789
+ ui.closeMenu();
790
+ if (this.menuShown && !this.dockMenu && n > 0) {
791
+ const total = Math.min(n, MENU_HEIGHT) + 1; // items + hint line
792
+ process.stdout.write(`\x1b[${total}A\r\x1b[J`); // erase the menu in place
793
+ }
794
+ this.menuShown = false;
795
+ this.dockMenu = false;
796
+ // Restore the draft that was live before the picker opened, so a menu never
797
+ // cancels what you were typing.
798
+ if (this.selectDraft) {
799
+ this.buffer = this.selectDraft.buffer;
800
+ this.cursor = this.selectDraft.cursor;
801
+ this.histIndex = this.selectDraft.histIndex;
802
+ this.selectDraft = null;
803
+ this.redraw();
804
+ }
805
+ resolve?.(result);
806
+ }
807
+ /** Draw the menu; re-renders in place by rewinding `total` lines when already shown. */
808
+ renderMenu() {
809
+ if (this.selectItems === null)
810
+ return;
811
+ const n = this.selectItems.length;
812
+ if (ui.menu(this.selectItems, this.selectIndex, this.selectTop, () => this.selectFinish(null))) {
813
+ this.dockMenu = true;
814
+ this.menuShown = true;
815
+ return;
816
+ }
817
+ const height = Math.min(n, MENU_HEIGHT);
818
+ if (this.selectIndex < this.selectTop)
819
+ this.selectTop = this.selectIndex;
820
+ if (this.selectIndex >= this.selectTop + height)
821
+ this.selectTop = this.selectIndex - height + 1;
822
+ const total = height + 1;
823
+ if (this.menuShown)
824
+ process.stdout.write(`\x1b[${total}A`);
825
+ for (let i = 0; i < height; i++) {
826
+ process.stdout.write('\r\x1b[K' + this.renderMenuItem(this.selectTop + i) + '\n');
827
+ }
828
+ process.stdout.write('\r\x1b[K' + dim(`↑/↓ move · enter select · ctrl+c cancel (${n} options)`) + '\n');
829
+ this.menuShown = true;
830
+ }
831
+ renderMenuItem(i) {
832
+ const label = this.selectItems[i];
833
+ return i === this.selectIndex ? `${orange('›')} ${bold(label)}` : `${dim(' ' + label)}`;
834
+ }
835
+ /** Enter reverse history search; the draft is saved so Esc restores it untouched. */
836
+ searchStart() {
837
+ if (!this.history.length || this.questionResolve)
838
+ return;
839
+ this.searchDraft = { buffer: this.buffer, cursor: this.cursor, histIndex: this.histIndex };
840
+ this.searching = true;
841
+ this.searchQuery = '';
842
+ this.searchRecompute();
843
+ this.searchPos = Math.max(0, this.searchMatches.length - 1);
844
+ this.searchApply();
845
+ }
846
+ searchRecompute() {
847
+ const query = this.searchQuery.toLowerCase();
848
+ this.searchMatches = [];
849
+ for (let index = 0; index < this.history.length; index++) {
850
+ if (!query || this.history[index].toLowerCase().includes(query))
851
+ this.searchMatches.push(index);
852
+ }
853
+ }
854
+ /** Walk the matching history: -1 is older (Ctrl+R/↑), +1 newer (↓). */
855
+ searchMove(delta) {
856
+ if (!this.searchMatches.length)
857
+ return;
858
+ const next = Math.max(0, Math.min(this.searchMatches.length - 1, this.searchPos + delta));
859
+ if (next === this.searchPos)
860
+ return;
861
+ this.searchPos = next;
862
+ this.searchApply();
863
+ }
864
+ searchApply() {
865
+ const match = this.searchMatches[this.searchPos];
866
+ if (match !== undefined && this.history[match] !== undefined)
867
+ this.setLine(this.history[match]);
868
+ this.renderSearch();
869
+ this.redraw();
870
+ }
871
+ searchAppend(char) {
872
+ this.searchQuery += char;
873
+ this.searchRecompute();
874
+ this.searchPos = Math.max(0, this.searchMatches.length - 1);
875
+ this.searchApply();
876
+ }
877
+ searchBackspace() {
878
+ this.searchQuery = this.searchQuery.slice(0, -1);
879
+ this.searchRecompute();
880
+ this.searchPos = Math.max(0, this.searchMatches.length - 1);
881
+ this.searchApply();
882
+ }
883
+ /** Keep the matched history line as the draft (press Enter again to submit). */
884
+ searchAccept() {
885
+ const found = this.searchMatches.length > 0;
886
+ this.searching = false;
887
+ this.searchMatches = [];
888
+ this.searchQuery = '';
889
+ if (this.selectEscapeTimer)
890
+ clearTimeout(this.selectEscapeTimer);
891
+ this.selectEscapeTimer = null;
892
+ this.esc = '';
893
+ ui.clearSearch();
894
+ if (found) {
895
+ this.cursor = this.buffer.length;
896
+ }
897
+ else if (this.searchDraft) {
898
+ this.buffer = this.searchDraft.buffer;
899
+ this.cursor = this.searchDraft.cursor;
900
+ }
901
+ this.searchDraft = null;
902
+ this.redraw();
903
+ }
904
+ /** Restore the exact pre-search draft and close the search row. */
905
+ searchCancel() {
906
+ if (!this.searching)
907
+ return;
908
+ this.searching = false;
909
+ if (this.selectEscapeTimer)
910
+ clearTimeout(this.selectEscapeTimer);
911
+ this.selectEscapeTimer = null;
912
+ this.esc = '';
913
+ this.searchMatches = [];
914
+ this.searchQuery = '';
915
+ if (this.searchDraft) {
916
+ this.buffer = this.searchDraft.buffer;
917
+ this.cursor = this.searchDraft.cursor;
918
+ this.histIndex = this.searchDraft.histIndex;
919
+ }
920
+ this.searchDraft = null;
921
+ ui.clearSearch();
922
+ this.redraw();
923
+ }
924
+ renderSearch() {
925
+ if (!this.searching)
926
+ return;
927
+ const total = this.searchMatches.length;
928
+ if (!total) {
929
+ ui.setSearch(this.searchQuery, this.buffer, 0, 0);
930
+ return;
931
+ }
932
+ const match = this.history[this.searchMatches[this.searchPos]] ?? '';
933
+ ui.setSearch(this.searchQuery, match, total - this.searchPos, total); // newest-first rank
934
+ }
935
+ /** Search-mode keys run here; everything else (paste/panels) is intentionally bypassed. */
936
+ handleSearch(chars, i) {
937
+ const c = chars[i];
938
+ if (this.esc) {
939
+ if (this.selectEscapeTimer) {
940
+ clearTimeout(this.selectEscapeTimer);
941
+ this.selectEscapeTimer = null;
942
+ }
943
+ this.esc += c;
944
+ if (this.escIsComplete()) {
945
+ const sequence = this.esc;
946
+ this.esc = '';
947
+ if (sequence === '\x1b[A')
948
+ this.searchMove(-1);
949
+ else if (sequence === '\x1b[B')
950
+ this.searchMove(1);
951
+ // other sequences are ignored while searching
952
+ }
953
+ return i + 1;
954
+ }
955
+ if (c === '\x1b') {
956
+ this.esc = c;
957
+ this.selectEscapeTimer = setTimeout(() => { this.esc = ''; this.searchCancel(); }, 100);
958
+ return i + 1;
959
+ }
960
+ if (c === '\x12') {
961
+ this.searchMove(-1);
962
+ return i + 1;
963
+ }
964
+ if (c === '\r' || c === '\n') {
965
+ this.searchAccept();
966
+ return chars.length;
967
+ }
968
+ if (c === '\x03' || c === '\x04') {
969
+ this.searchCancel();
970
+ return chars.length;
971
+ }
972
+ if (c === '\x7f' || c === '\b') {
973
+ this.searchBackspace();
974
+ return i + 1;
975
+ }
976
+ if (c >= ' ') {
977
+ this.searchAppend(c);
978
+ return i + 1;
979
+ }
980
+ return i + 1;
981
+ }
982
+ }