subrouter-cli 0.0.0-stage → 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.
Files changed (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
@@ -0,0 +1,726 @@
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 "…" | sub` keeps working.
5
+
6
+ import readline from 'node:readline';
7
+ import { StringDecoder } from 'node:string_decoder';
8
+ import { ui, violet, bold, dim } from './ui.ts';
9
+ import { matchingCommands } from './commands.ts';
10
+
11
+ export interface LineInputHandlers {
12
+ onLine(line: string): void;
13
+ onInterrupt(): void;
14
+ onEof(): void;
15
+ onPasteImage?(): void;
16
+ onClearImages?(): void;
17
+ }
18
+
19
+ const PASTE_START = '\x1b[200~';
20
+ const PASTE_END = '\x1b[201~';
21
+ /** Visible rows in the picker menu; the list scrolls under this window when longer. */
22
+ const MENU_HEIGHT = 15;
23
+
24
+ /** Pure key-to-action core — separated so the editor logic stays testable. */
25
+ export function editLine(
26
+ chars: string,
27
+ state: { buffer: string; cursor: number },
28
+ ): { buffer: string; cursor: number; submit?: string; interrupt?: boolean } {
29
+ const out = { ...state };
30
+ for (const c of chars) {
31
+ if (c === '\r' || c === '\n') {
32
+ return { ...out, submit: out.buffer };
33
+ }
34
+ if (c === '\x03') {
35
+ return { buffer: '', cursor: 0, interrupt: true };
36
+ }
37
+ if (c === '\x7f' || c === '\b') {
38
+ if (out.cursor > 0) {
39
+ out.buffer = out.buffer.slice(0, out.cursor - 1) + out.buffer.slice(out.cursor);
40
+ out.cursor--;
41
+ }
42
+ continue;
43
+ }
44
+ if (c === '\x01') {
45
+ out.cursor = 0; // Ctrl+A home
46
+ continue;
47
+ }
48
+ if (c === '\x05') {
49
+ out.cursor = out.buffer.length; // Ctrl+E end
50
+ continue;
51
+ }
52
+ if (c === '\x15') {
53
+ out.buffer = ''; // Ctrl+U kill line
54
+ out.cursor = 0;
55
+ continue;
56
+ }
57
+ if (c === '\x0b') {
58
+ out.buffer = out.buffer.slice(0, out.cursor); // Ctrl+K kill to end
59
+ continue;
60
+ }
61
+ if (c >= ' ') {
62
+ out.buffer = out.buffer.slice(0, out.cursor) + c + out.buffer.slice(out.cursor);
63
+ out.cursor++;
64
+ }
65
+ // other control chars are ignored
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export class LineInput {
71
+ private handlers: LineInputHandlers;
72
+ private prompt = violet('❯ ');
73
+ private model = '';
74
+ private buffer = '';
75
+ private cursor = 0;
76
+ private history: string[] = [];
77
+ private histIndex = 0;
78
+ private pasting = false;
79
+ private pasteBuf = '';
80
+ private esc = '';
81
+ private decoder = new StringDecoder('utf8');
82
+ private fallback: readline.Interface | null = null;
83
+ private questionResolve: ((answer: string) => void) | null = null;
84
+ private echoQuestion = true;
85
+ private stopped = false;
86
+ private paused = false;
87
+ private working = false;
88
+ private questionDraft: { buffer: string; cursor: number; histIndex: number; prompt: string } | null = null;
89
+ private selectItems: string[] | null = null;
90
+ private selectIndex = 0;
91
+ private selectTop = 0;
92
+ private selectResolve: ((index: number | null) => void) | null = null;
93
+ private selectEsc = '';
94
+ private menuShown = false;
95
+ private dockMenu = false;
96
+ private selectEscapeTimer: NodeJS.Timeout | null = null;
97
+ private paletteOpen = false;
98
+ private paletteIndex = 0;
99
+ private paletteText = '';
100
+ private paletteDismissed = false;
101
+ private viewResolve: (() => void) | null = null;
102
+ private viewRefresh: (() => Promise<string[]>) | null = null;
103
+ private viewEsc = '';
104
+ private viewRefreshing = false;
105
+ private navigation = '';
106
+ private navigationTimer: NodeJS.Timeout | null = null;
107
+
108
+ /** Live read-only command panel; arrows scroll, r refreshes, Enter/Esc dismisses. */
109
+ view(lines: string[], refresh: () => Promise<string[]>): Promise<void> {
110
+ this.closePalette();
111
+ if (!ui.panel(lines, () => this.closeView())) {
112
+ lines.forEach((line) => ui.say(line));
113
+ return Promise.resolve();
114
+ }
115
+ this.viewRefresh = refresh;
116
+ return new Promise((resolve) => { this.viewResolve = resolve; });
117
+ }
118
+
119
+ updateView(lines: string[]): void {
120
+ if (this.viewResolve) ui.panel(lines);
121
+ }
122
+
123
+ private closeView(): void {
124
+ const resolve = this.viewResolve;
125
+ this.viewResolve = null;
126
+ this.viewRefresh = null;
127
+ this.viewEsc = '';
128
+ this.viewRefreshing = false;
129
+ if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
130
+ this.selectEscapeTimer = null;
131
+ ui.closePanel();
132
+ resolve?.();
133
+ }
134
+
135
+ private closePalette(): void {
136
+ if (this.paletteOpen) ui.closeMenu();
137
+ this.paletteOpen = false;
138
+ if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
139
+ this.selectEscapeTimer = null;
140
+ this.esc = '';
141
+ }
142
+
143
+ constructor(handlers: LineInputHandlers) {
144
+ this.handlers = handlers;
145
+ }
146
+
147
+ /** Enable the editor; piped stdin falls back to plain readline. */
148
+ start(): void {
149
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
150
+ this.fallback = readline.createInterface({ input: process.stdin, output: process.stdout });
151
+ this.fallback.on('line', (line) => {
152
+ if (this.questionResolve) {
153
+ this.answerQuestion(line);
154
+ return;
155
+ }
156
+ this.handlers.onLine(line);
157
+ });
158
+ this.fallback.on('SIGINT', () => {
159
+ if (this.questionResolve) this.answerQuestion('n');
160
+ this.handlers.onInterrupt();
161
+ });
162
+ this.fallback.on('close', () => {
163
+ if (this.questionResolve) this.answerQuestion('n');
164
+ this.handlers.onEof();
165
+ });
166
+ return;
167
+ }
168
+ process.stdin.setRawMode(true);
169
+ process.stdin.resume();
170
+ process.stdin.on('data', this.onData);
171
+ process.on('SIGTERM', this.onSigterm);
172
+ this.redraw();
173
+ }
174
+
175
+ stop(): void {
176
+ if (this.stopped) return;
177
+ this.stopped = true;
178
+ if (this.navigationTimer) clearTimeout(this.navigationTimer);
179
+ this.navigationTimer = null;
180
+ this.navigation = '';
181
+ this.closePalette();
182
+ if (this.viewResolve) this.closeView();
183
+ ui.clearComposer();
184
+ if (this.selectResolve) this.selectFinish(null);
185
+ ui.closeDock();
186
+ if (this.fallback) {
187
+ this.fallback.close();
188
+ return;
189
+ }
190
+ process.stdin.off('data', this.onData);
191
+ process.off('SIGTERM', this.onSigterm);
192
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
193
+ process.stdin.pause();
194
+ }
195
+
196
+ /** Stop and fire onEof exactly once (the /exit and Ctrl+D path). */
197
+ close(): void {
198
+ if (this.stopped) return;
199
+ if (this.fallback) {
200
+ this.fallback.close(); // its 'close' handler fires onEof
201
+ return;
202
+ }
203
+ this.stop();
204
+ this.handlers.onEof();
205
+ }
206
+
207
+ /** Pause normal editing during command/clipboard loading; interrupts stay responsive. */
208
+ pause(): void {
209
+ this.paused = true;
210
+ ui.clearComposer();
211
+ }
212
+
213
+ resume(): void {
214
+ this.paused = false;
215
+ this.redraw();
216
+ }
217
+
218
+ /** Live drafting during agent turns; submitted steering renders at a safe boundary. */
219
+ setWorking(working: boolean): void {
220
+ this.working = working;
221
+ this.redraw();
222
+ }
223
+
224
+ /** Release an open command panel/picker before an agent permission question. */
225
+ cancelControls(): void {
226
+ if (this.viewResolve) this.closeView();
227
+ if (this.selectResolve) this.selectFinish(null);
228
+ if (this.questionResolve) this.answerQuestion('n');
229
+ this.closePalette();
230
+ }
231
+
232
+ /** Put unsent steering back in the editor after cancellation/error/round limit. */
233
+ restoreDraft(lines: string[]): void {
234
+ if (!lines.length) return;
235
+ const prefix = lines.join(' ') + (this.buffer ? ' ' : '');
236
+ this.buffer = prefix + this.buffer;
237
+ this.cursor += prefix.length;
238
+ this.redraw();
239
+ }
240
+
241
+ private answerQuestion(answer: string): void {
242
+ const resolve = this.questionResolve;
243
+ this.questionResolve = null;
244
+ ui.closeApproval();
245
+ this.echoQuestion = true;
246
+ if (this.questionDraft) {
247
+ const draft = this.questionDraft;
248
+ this.buffer = draft.buffer;
249
+ this.cursor = draft.cursor;
250
+ this.histIndex = draft.histIndex;
251
+ this.prompt = draft.prompt;
252
+ this.questionDraft = null;
253
+ }
254
+ this.redraw();
255
+ resolve?.(answer);
256
+ }
257
+
258
+ /** One-shot question mode temporarily replaces, then restores, the current draft. */
259
+ question(prompt: string, echoAnswer = true): Promise<string> {
260
+ this.questionDraft = { buffer: this.buffer, cursor: this.cursor, histIndex: this.histIndex, prompt: this.prompt };
261
+ this.echoQuestion = echoAnswer;
262
+ this.prompt = prompt;
263
+ this.buffer = '';
264
+ this.cursor = 0;
265
+ return new Promise((resolve) => {
266
+ this.questionResolve = resolve;
267
+ this.redraw();
268
+ });
269
+ }
270
+
271
+ /** Interactive picker: arrow-key menu in a TTY, numbered list in a pipe. Resolves to
272
+ * the chosen index (0-based) or null when cancelled. `current` pre-highlights a row. */
273
+ select(items: string[], current = 0): Promise<number | null> {
274
+ if (items.length === 0) return Promise.resolve(null);
275
+ if (this.fallback) {
276
+ const width = String(items.length).length;
277
+ items.forEach((label, i) => ui.say(`${String(i + 1).padStart(width)}. ${label}`));
278
+ return this.question('pick a number (Enter to cancel): ').then((answer) => {
279
+ const n = Number(answer);
280
+ return Number.isInteger(n) && n >= 1 && n <= items.length ? n - 1 : null;
281
+ });
282
+ }
283
+ this.closePalette();
284
+ this.selectItems = items;
285
+ this.selectIndex = Math.max(0, Math.min(current, items.length - 1));
286
+ this.selectTop = 0;
287
+ this.selectEsc = '';
288
+ ui.clearLine(); // the menu replaces the prompt line
289
+ this.menuShown = false;
290
+ this.renderMenu();
291
+ return new Promise((resolve) => {
292
+ this.selectResolve = resolve;
293
+ });
294
+ }
295
+
296
+ setModel(model: string): void {
297
+ this.model = model;
298
+ }
299
+
300
+ /** Re-render prompt + buffer (called after output lands on the terminal). */
301
+ redraw(): void {
302
+ if (this.stopped || this.paused || this.fallback || this.selectItems || this.viewResolve) return;
303
+ if (this.questionResolve) {
304
+ if (!ui.setApprovalChoice(this.buffer, this.cursor)) ui.line(this.prompt, this.buffer, this.cursor);
305
+ }
306
+ else {
307
+ if (this.buffer !== this.paletteText) {
308
+ this.paletteText = this.buffer;
309
+ this.paletteIndex = 0;
310
+ this.paletteDismissed = false;
311
+ }
312
+ const commands = this.paletteDismissed ? [] : matchingCommands(this.buffer);
313
+ if (commands.length) {
314
+ this.paletteIndex = Math.min(this.paletteIndex, commands.length - 1);
315
+ this.paletteOpen = ui.menu(commands.map((c) => `${c.id.padEnd(9)} ${c.description}`), this.paletteIndex, 0,
316
+ () => { this.paletteDismissed = true; this.closePalette(); },
317
+ '↑/↓ choose · tab complete · enter run · esc close');
318
+ } else this.closePalette();
319
+ ui.composer(this.model, this.prompt, this.buffer, this.cursor);
320
+ }
321
+ }
322
+
323
+ private setLine(text: string): void {
324
+ this.buffer = text;
325
+ this.cursor = text.length;
326
+ }
327
+
328
+ private submit(text: string): void {
329
+ this.closePalette();
330
+ if (this.working && !this.questionResolve) ui.clearComposer(); // queued steering renders at the next safe boundary
331
+ else if (text.trimStart().startsWith('/') && !this.questionResolve && process.stdout.isTTY) ui.clearComposer();
332
+ else if ((!this.questionResolve || this.echoQuestion) && !ui.hasApproval()) ui.submitInput(this.prompt, text);
333
+ this.buffer = '';
334
+ this.cursor = 0;
335
+ this.prompt = violet('❯ ');
336
+ if (this.questionResolve) {
337
+ this.answerQuestion(text);
338
+ return;
339
+ }
340
+ if (text !== '' && this.history.at(-1) !== text) {
341
+ this.history.push(text);
342
+ }
343
+ this.histIndex = this.history.length;
344
+ this.handlers.onLine(text);
345
+ }
346
+
347
+ private onData = (chunk: Buffer): void => {
348
+ for (const c of this.decoder.write(chunk)) {
349
+ if (this.stopped) return;
350
+ if (this.pasting) {
351
+ this.pasteBuf += c;
352
+ if (this.pasteBuf.endsWith(PASTE_END)) {
353
+ this.pasteBuf = this.pasteBuf.slice(0, -PASTE_END.length);
354
+ this.deliver(PASTE_END);
355
+ }
356
+ continue;
357
+ }
358
+ if (c === '\x03' || c === '\x04') {
359
+ if (this.navigationTimer) clearTimeout(this.navigationTimer);
360
+ this.navigationTimer = null;
361
+ this.navigation = '';
362
+ this.deliver(c);
363
+ continue;
364
+ }
365
+ if (this.navigation || c === '\x1b') {
366
+ this.navigation += c;
367
+ if (this.navigationTimer) clearTimeout(this.navigationTimer);
368
+ this.navigationTimer = null;
369
+ if (/^\x1b\[[\d;<?]*[A-Za-z~]$/.test(this.navigation) || this.navigation.length > 80 ||
370
+ this.navigation.length > 1 && !this.navigation.startsWith('\x1b[')) {
371
+ const sequence = this.navigation;
372
+ this.navigation = '';
373
+ if (!this.scrollKey(sequence)) this.deliver(sequence);
374
+ } else {
375
+ this.navigationTimer = setTimeout(() => {
376
+ const sequence = this.navigation;
377
+ this.navigation = '';
378
+ this.navigationTimer = null;
379
+ if (sequence === '\x1b' && !this.paused) {
380
+ if (this.viewResolve) this.closeView();
381
+ else if (this.selectResolve) this.selectFinish(null);
382
+ else if (this.questionResolve) this.answerQuestion('n');
383
+ else {
384
+ this.paletteDismissed = true;
385
+ this.closePalette();
386
+ this.redraw();
387
+ }
388
+ }
389
+ }, 100);
390
+ }
391
+ continue;
392
+ }
393
+ this.deliver(c);
394
+ }
395
+ };
396
+
397
+ private deliver(chars: string): void {
398
+ if (this.stopped) return;
399
+ if (this.paused) {
400
+ if (chars.includes('\x04')) this.close();
401
+ else if (chars.includes('\x03')) this.handlers.onInterrupt();
402
+ } else this.handle(chars);
403
+ }
404
+
405
+ private scrollKey(sequence: string): boolean {
406
+ const mouse = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(sequence);
407
+ const page = sequence === '\x1b[5~' ? -1 : sequence === '\x1b[6~' ? 1 : 0;
408
+ if (!mouse && !page) return false;
409
+ const button = mouse ? Number(mouse[1]) : 0;
410
+ if (mouse && (button & 64) === 0) return true; // discard click reports
411
+ const direction = page || ((button & 1) === 0 ? -1 : 1);
412
+ const delta = direction * (page ? 8 : 3);
413
+ if (this.viewResolve) ui.scrollPanel(delta);
414
+ else if (this.selectItems) this.selectMove(delta);
415
+ else if (this.paletteOpen) {
416
+ this.paletteIndex = Math.max(0, Math.min(matchingCommands(this.buffer).length - 1, this.paletteIndex + direction));
417
+ this.redraw();
418
+ } else if (this.questionResolve && ui.hasApproval()) ui.scrollApproval(delta);
419
+ else ui.scrollChat(-delta);
420
+ return true;
421
+ }
422
+
423
+ private onSigterm = (): void => {
424
+ this.stop();
425
+ process.exit(0);
426
+ };
427
+
428
+ private handle(chars: string): void {
429
+ if (this.viewResolve) {
430
+ this.handleView(chars);
431
+ return;
432
+ }
433
+ if (this.selectResolve) {
434
+ this.handleSelect(chars);
435
+ return;
436
+ }
437
+ let i = 0;
438
+ while (i < chars.length) {
439
+ const c = chars[i];
440
+ if (this.pasting) {
441
+ const end = chars.indexOf(PASTE_END, i);
442
+ if (end === -1) {
443
+ this.pasteBuf += chars.slice(i);
444
+ return;
445
+ }
446
+ this.pasteBuf += chars.slice(i, end);
447
+ i = end + PASTE_END.length;
448
+ this.pasting = false;
449
+ const pasted = this.pasteBuf;
450
+ this.pasteBuf = '';
451
+ if (pasted.includes('\n')) {
452
+ if (pasted.trimEnd() !== '') this.setLine(pasted.replace(/\n/g, ' ').trimEnd());
453
+ // A trailing-newline paste submits as one turn (bracketed-paste contract).
454
+ if (pasted.endsWith('\n')) {
455
+ this.submit(this.buffer);
456
+ this.redraw();
457
+ return;
458
+ }
459
+ this.redraw();
460
+ continue;
461
+ }
462
+ this.insert(pasted);
463
+ this.redraw();
464
+ continue;
465
+ }
466
+ if (this.esc) {
467
+ if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
468
+ this.selectEscapeTimer = null;
469
+ this.esc += c;
470
+ if (this.escIsComplete()) {
471
+ this.handleEscape(this.esc);
472
+ this.esc = '';
473
+ }
474
+ i++;
475
+ continue;
476
+ }
477
+ if (c === '\x1b') {
478
+ this.esc = c;
479
+ if (this.paletteOpen) {
480
+ this.selectEscapeTimer = setTimeout(() => {
481
+ this.esc = '';
482
+ this.paletteDismissed = true;
483
+ this.closePalette();
484
+ this.redraw();
485
+ }, 100);
486
+ }
487
+ i++;
488
+ continue;
489
+ }
490
+ if (!this.questionResolve && (c === '\x16' || c === '\x18')) {
491
+ this.closePalette();
492
+ this.paletteDismissed = true;
493
+ if (c === '\x16') this.handlers.onPasteImage?.();
494
+ else this.handlers.onClearImages?.();
495
+ i++;
496
+ continue;
497
+ }
498
+ if (c === '\t' && this.paletteOpen) {
499
+ const chosen = matchingCommands(this.buffer)[this.paletteIndex];
500
+ if (chosen) this.setLine(chosen.id + ' ');
501
+ this.redraw();
502
+ i++;
503
+ continue;
504
+ }
505
+ if (c === '\r' || c === '\n') {
506
+ const chosen = this.paletteOpen ? matchingCommands(this.buffer)[this.paletteIndex] : undefined;
507
+ this.submit(chosen?.id ?? this.buffer);
508
+ i++;
509
+ continue;
510
+ }
511
+ if (c === '\x03') {
512
+ if (!this.working || this.questionResolve) this.setLine('');
513
+ this.redraw();
514
+ if (this.questionResolve) this.answerQuestion('n'); // cancellation must never approve a write
515
+ this.handlers.onInterrupt();
516
+ i++;
517
+ continue;
518
+ }
519
+ if (c === '\x04') {
520
+ if (this.buffer === '' || this.questionResolve) {
521
+ if (this.questionResolve) this.answerQuestion('n');
522
+ this.close();
523
+ }
524
+ i++;
525
+ continue;
526
+ }
527
+ const result = editLine(c, { buffer: this.buffer, cursor: this.cursor });
528
+ this.buffer = result.buffer;
529
+ this.cursor = result.cursor;
530
+ if (result.submit !== undefined) this.submit(result.submit);
531
+ else this.redraw();
532
+ i++;
533
+ }
534
+ }
535
+
536
+ private insert(text: string): void {
537
+ this.buffer = this.buffer.slice(0, this.cursor) + text + this.buffer.slice(this.cursor);
538
+ this.cursor += text.length;
539
+ }
540
+
541
+ private escIsComplete(): boolean {
542
+ const e = this.esc;
543
+ if (e === PASTE_START || e === PASTE_END) return true;
544
+ if (!e.startsWith('\x1b[')) return e.length >= 2;
545
+ const last = e.at(-1);
546
+ return last === '~' || last === 'A' || last === 'B' || last === 'C' || last === 'D' || last === 'H' || last === 'F';
547
+ }
548
+
549
+ private handleEscape(seq: string): void {
550
+ if (this.paletteOpen && (seq === '\x1b[A' || seq === '\x1b[B')) {
551
+ const delta = seq === '\x1b[A' ? -1 : 1;
552
+ this.paletteIndex = Math.max(0, Math.min(matchingCommands(this.buffer).length - 1, this.paletteIndex + delta));
553
+ this.redraw();
554
+ return;
555
+ }
556
+ if (seq === PASTE_START) {
557
+ this.pasting = true;
558
+ this.pasteBuf = '';
559
+ return;
560
+ }
561
+ if (seq === PASTE_END) {
562
+ this.pasting = false;
563
+ return;
564
+ }
565
+ switch (seq) {
566
+ case '\x1b[A': // up — history
567
+ if (this.history.length > 0) {
568
+ this.histIndex = Math.max(0, this.histIndex - 1);
569
+ this.setLine(this.history[this.histIndex] ?? '');
570
+ this.redraw();
571
+ }
572
+ break;
573
+ case '\x1b[B': // down — history
574
+ if (this.histIndex < this.history.length) {
575
+ this.histIndex = Math.min(this.history.length, this.histIndex + 1);
576
+ this.setLine(this.history[this.histIndex] ?? '');
577
+ this.redraw();
578
+ }
579
+ break;
580
+ case '\x1b[C': // right
581
+ if (this.cursor < this.buffer.length) {
582
+ this.cursor++;
583
+ this.redraw();
584
+ }
585
+ break;
586
+ case '\x1b[D': // left
587
+ if (this.cursor > 0) {
588
+ this.cursor--;
589
+ this.redraw();
590
+ }
591
+ break;
592
+ case '\x1b[H': // home
593
+ this.cursor = 0;
594
+ this.redraw();
595
+ break;
596
+ case '\x1b[F': // end
597
+ this.cursor = this.buffer.length;
598
+ this.redraw();
599
+ break;
600
+ default:
601
+ break; // unknown sequences ignored
602
+ }
603
+ }
604
+
605
+ private handleView(chars: string): void {
606
+ for (const c of chars) {
607
+ if (this.viewEsc) {
608
+ if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
609
+ this.selectEscapeTimer = null;
610
+ this.viewEsc += c;
611
+ if (this.viewEsc === '\x1b[A' || this.viewEsc === '\x1b[B') {
612
+ ui.scrollPanel(this.viewEsc === '\x1b[A' ? -1 : 1);
613
+ this.viewEsc = '';
614
+ } else if (!this.viewEsc.startsWith('\x1b[') || this.viewEsc.length > 3) this.viewEsc = '';
615
+ continue;
616
+ }
617
+ if (c === '\x1b') {
618
+ this.viewEsc = c;
619
+ this.selectEscapeTimer = setTimeout(() => this.closeView(), 100);
620
+ } else if (c === '\r' || c === '\n' || c === '\x03') {
621
+ this.closeView();
622
+ return;
623
+ } else if (c === '\x04') {
624
+ this.close();
625
+ return;
626
+ } else if (c.toLowerCase() === 'r' && this.viewRefresh && !this.viewRefreshing) {
627
+ this.viewRefreshing = true;
628
+ const view = this.viewResolve;
629
+ void this.viewRefresh()
630
+ .then((lines) => { if (this.viewResolve === view) this.updateView(lines); })
631
+ .catch(() => { if (this.viewResolve === view) this.updateView(['Usage unavailable — r to retry']); })
632
+ .finally(() => { if (this.viewResolve === view) this.viewRefreshing = false; });
633
+ }
634
+ }
635
+ }
636
+
637
+ /** Select-mode keys: ↑/↓ (and j/k) move, Enter confirms, Ctrl+C cancels. */
638
+ private handleSelect(chars: string): void {
639
+ for (const c of chars) {
640
+ if (this.selectEsc) {
641
+ if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
642
+ this.selectEscapeTimer = null;
643
+ this.selectEsc += c;
644
+ if (this.selectEsc === '\x1b[A') {
645
+ this.selectMove(-1);
646
+ this.selectEsc = '';
647
+ } else if (this.selectEsc === '\x1b[B') {
648
+ this.selectMove(1);
649
+ this.selectEsc = '';
650
+ } else if (!this.selectEsc.startsWith('\x1b[') || this.selectEsc.length > 3) {
651
+ this.selectEsc = ''; // stray escape — ignore
652
+ }
653
+ continue;
654
+ }
655
+ if (c === '\x1b') {
656
+ this.selectEsc = c;
657
+ this.selectEscapeTimer = setTimeout(() => this.selectFinish(null), 100);
658
+ continue;
659
+ }
660
+ if (c === '\r' || c === '\n') {
661
+ this.selectFinish(this.selectIndex);
662
+ return;
663
+ }
664
+ if (c === '\x03' || c === '\x04') {
665
+ this.selectFinish(null);
666
+ return;
667
+ }
668
+ if (c === 'j' || c === 'J') this.selectMove(1);
669
+ else if (c === 'k' || c === 'K') this.selectMove(-1);
670
+ // other keys are ignored while the menu is open
671
+ }
672
+ }
673
+
674
+ private selectMove(delta: number): void {
675
+ if (this.selectItems === null) return;
676
+ const n = this.selectItems.length;
677
+ this.selectIndex = Math.max(0, Math.min(n - 1, this.selectIndex + delta));
678
+ this.renderMenu();
679
+ }
680
+
681
+ private selectFinish(result: number | null): void {
682
+ const resolve = this.selectResolve;
683
+ const n = this.selectItems?.length ?? 0;
684
+ this.selectResolve = null;
685
+ this.selectItems = null;
686
+ this.selectEsc = '';
687
+ this.selectIndex = 0;
688
+ this.selectTop = 0;
689
+ if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
690
+ this.selectEscapeTimer = null;
691
+ if (this.dockMenu) ui.closeMenu();
692
+ if (this.menuShown && !this.dockMenu && n > 0) {
693
+ const total = Math.min(n, MENU_HEIGHT) + 1; // items + hint line
694
+ process.stdout.write(`\x1b[${total}A\r\x1b[J`); // erase the menu in place
695
+ }
696
+ this.menuShown = false;
697
+ this.dockMenu = false;
698
+ resolve?.(result);
699
+ }
700
+
701
+ /** Draw the menu; re-renders in place by rewinding `total` lines when already shown. */
702
+ private renderMenu(): void {
703
+ if (this.selectItems === null) return;
704
+ const n = this.selectItems.length;
705
+ if (ui.menu(this.selectItems, this.selectIndex, this.selectTop, () => this.selectFinish(null))) {
706
+ this.dockMenu = true;
707
+ this.menuShown = true;
708
+ return;
709
+ }
710
+ const height = Math.min(n, MENU_HEIGHT);
711
+ if (this.selectIndex < this.selectTop) this.selectTop = this.selectIndex;
712
+ if (this.selectIndex >= this.selectTop + height) this.selectTop = this.selectIndex - height + 1;
713
+ const total = height + 1;
714
+ if (this.menuShown) process.stdout.write(`\x1b[${total}A`);
715
+ for (let i = 0; i < height; i++) {
716
+ process.stdout.write('\r\x1b[K' + this.renderMenuItem(this.selectTop + i) + '\n');
717
+ }
718
+ process.stdout.write('\r\x1b[K' + dim(`↑/↓ move · enter select · ctrl+c cancel (${n} options)`) + '\n');
719
+ this.menuShown = true;
720
+ }
721
+
722
+ private renderMenuItem(i: number): string {
723
+ const label = this.selectItems![i];
724
+ return i === this.selectIndex ? `${violet('›')} ${bold(label)}` : `${dim(' ' + label)}`;
725
+ }
726
+ }