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
package/src/ui.ts ADDED
@@ -0,0 +1,813 @@
1
+ // ui.ts — sole terminal writer. The chat scrolls above a bottom-anchored composer;
2
+ // pipes and very small terminals retain the simple line-oriented interface.
3
+
4
+ import { stripVTControlCharacters } from 'node:util';
5
+ import { ChatViewport, type ChatPosition } from './chatviewport.ts';
6
+
7
+ const colorEnabled = (): boolean => process.stdout.isTTY === true && !process.env.NO_COLOR;
8
+ const animated = colorEnabled;
9
+ function wrap(text: string, code: string): string {
10
+ return colorEnabled() ? `\x1b[${code}m${text}\x1b[0m` : text;
11
+ }
12
+ export const dim = (t: string): string => wrap(t, '2');
13
+ export const yellow = (t: string): string => wrap(t, '93');
14
+ export const red = (t: string): string => wrap(t, '91');
15
+ export const green = (t: string): string => wrap(t, '92');
16
+ export const bold = (t: string): string => wrap(t, '1');
17
+ // Subrouter accents: periwinkle blue (#8fa7ff) and violet (#a674ff).
18
+ export const accent = (t: string): string => wrap(t, '38;2;143;167;255');
19
+ export const violet = (t: string): string => wrap(t, '38;2;166;116;255');
20
+
21
+ function usageStyle(text: string): string {
22
+ return text
23
+ .replace(/\b([a-z][\w-]*):/g, (label, name) => ['account', 'usage', 'quota'].includes(name) ? dim(label) : accent(bold(label)))
24
+ .replace(/([\d,]+) in \/ ([\d,]+) out/, (_, input, output) => `${accent(input)} ${dim('in')} / ${violet(output)} ${dim('out')}`)
25
+ .replace(/\b(\d+)%/g, (percent, value) => Number(value) < 15 ? red(bold(percent)) : Number(value) < 40 ? yellow(bold(percent)) : accent(bold(percent)))
26
+ .replace(/([\d,]+) rpm/, (_, value) => `${accent(value)} ${dim('rpm')}`)
27
+ .replace(/\b(day|week)\b/g, (label) => dim(label))
28
+ .replace(/\bunlimited\b/g, (value) => green(value))
29
+ .replace(/\$[\d,.]+/g, (value) => accent(value))
30
+ .replace(/\b(unknown|not reported)\b/g, (value) => yellow(value))
31
+ .replace(/ · /g, dim(' · '));
32
+ }
33
+
34
+ // Dock labels must stay on one physical row, even with wide model names/paths.
35
+ function cells(c: string): number {
36
+ if (/\p{Mark}/u.test(c)) return 0;
37
+ const cp = c.codePointAt(0)!;
38
+ return cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf ||
39
+ cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
40
+ cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff00 && cp <= 0xff60 || cp >= 0x1f300) ? 2 : 1;
41
+ }
42
+ function clean(text: string): string {
43
+ return stripVTControlCharacters(text).replace(/[\x00-\x1f\x7f]/g, ' ');
44
+ }
45
+ function width(text: string): number {
46
+ return [...clean(text)].reduce((n, c) => n + cells(c), 0);
47
+ }
48
+ function fit(text: string, limit: number): string {
49
+ let out = '';
50
+ let used = 0;
51
+ for (const c of clean(text)) {
52
+ if (used + cells(c) > limit) break;
53
+ used += cells(c);
54
+ out += c;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export interface UiSink {
60
+ streamText(text: string): void;
61
+ streamReasoning(text: string): void;
62
+ endTurn(): void;
63
+ say(line: string): void;
64
+ warn(line: string): void;
65
+ error(line: string): void;
66
+ info(line: string): void;
67
+ }
68
+
69
+ class UiImpl implements UiSink {
70
+ private lineOpen = false;
71
+ private composerVisible = false;
72
+ private dock = false;
73
+ private dockRows = new Map<number, string>();
74
+ private chatRows = 0;
75
+ private chatRow = 1;
76
+ private chatColumn = 1;
77
+ private restoredWrap = false;
78
+ private viewport = new ChatViewport();
79
+ private model = '';
80
+ private prompt = '❯ ';
81
+ private text = '';
82
+ private cursor = 0;
83
+ private mode = 'ask before writes';
84
+ private effort = 'auto';
85
+ private attachmentNames: string[] = [];
86
+ private status = 'ready';
87
+ private queuedSteering: number | null = null;
88
+ private steeringMessages: string[] = [];
89
+ private usage = 'usage unavailable';
90
+ private menuItems: string[] | null = null;
91
+ private menuIndex = 0;
92
+ private menuTop = 0;
93
+ private menuCancel: (() => void) | null = null;
94
+ private menuHint = '↑/↓ move · enter select · esc/ctrl+c cancel';
95
+ private panelLines: string[] | null = null;
96
+ private panelTop = 0;
97
+ private panelCancel: (() => void) | null = null;
98
+ private limits = '';
99
+ private notice = '';
100
+ private commandMode = false;
101
+ private approvalTitle = '';
102
+ private approvalLines: string[] = [];
103
+ private approvalHint = '';
104
+ private approvalChoice = '';
105
+ private approvalCursor = 0;
106
+ private approvalTop = 0;
107
+ private agentActivity = '';
108
+ private thinkingText = '';
109
+ private toolCount = 0;
110
+ private previewTitle = '';
111
+ private previewLines: string[] = [];
112
+ private cardStart: ChatPosition | null = null;
113
+ private cardEnd: ChatPosition | null = null;
114
+
115
+ private approvalRows(): string[] {
116
+ const limit = Math.max(1, (process.stdout.columns || 80) - 5);
117
+ return this.approvalLines.flatMap((line) => {
118
+ const rows: string[] = [];
119
+ let part = '', used = 0;
120
+ for (const char of line) {
121
+ if (part && used + cells(char) > limit) { rows.push(part); part = ''; used = 0; }
122
+ part += char;
123
+ used += cells(char);
124
+ }
125
+ rows.push(part);
126
+ return rows;
127
+ });
128
+ }
129
+
130
+ private approvalHeight(): number {
131
+ if (!this.approvalTitle) return 0;
132
+ const base = this.menuItems || this.panelLines ? 3 : this.commandMode ? 4 : this.limits ? 6 : 5;
133
+ return Math.min(this.approvalRows().length + 3, Math.max(3, (process.stdout.rows || 24) - base - 2));
134
+ }
135
+ private approvalPageSize(): number {
136
+ const height = this.approvalHeight();
137
+ return height - (height > 3 ? 3 : 2);
138
+ }
139
+ private steeringHeight(): number {
140
+ if (!this.steeringMessages.length || this.menuItems || this.panelLines || this.commandMode) return 0;
141
+ const room = (process.stdout.rows || 24) - (this.limits ? 6 : 5) - this.approvalHeight() - 3;
142
+ return Math.min(4, this.steeringMessages.length + 1, Math.max(0, room));
143
+ }
144
+ private dockHeight(): number { return (this.menuItems || this.panelLines ? 3 : this.commandMode ? 4 : this.limits ? 6 : 5) + this.approvalHeight() + this.steeringHeight(); }
145
+
146
+ setCommandMode(active: boolean): void {
147
+ this.commandMode = active;
148
+ if (!this.dock) return;
149
+ this.reflow();
150
+ this.drawDock();
151
+ }
152
+
153
+ startTool(name: string, preview?: { title: string; lines: string[] }): void {
154
+ this.previewTitle = preview ? clean(preview.title) : '';
155
+ this.previewLines = preview ? preview.lines.map(clean) : [];
156
+ this.setAgentActivity(`● ${this.previewTitle || name}`);
157
+ }
158
+
159
+ preview(title: string, lines: string[]): void {
160
+ this.previewTitle = clean(title);
161
+ this.previewLines = lines.map(clean);
162
+ this.setAgentActivity(`● ${this.previewTitle} · generating`);
163
+ }
164
+
165
+ beginTurn(): void {
166
+ if (this.viewport.offset) this.scrollChat(-Infinity);
167
+ this.toolCount = 0;
168
+ if (this.cardStart && this.cardEnd) this.viewport.release(this.cardStart, this.cardEnd);
169
+ this.cardStart = this.cardEnd = null;
170
+ this.clearThinking();
171
+ this.previewTitle = '';
172
+ this.previewLines = [];
173
+ this.setAgentActivity('');
174
+ }
175
+
176
+ private setAgentActivity(text: string): void {
177
+ this.agentActivity = text;
178
+ if (!this.dock || !text) return;
179
+ const columns = Math.max(4, (process.stdout.columns || 80) - 1);
180
+ const title = clean(text);
181
+ const styled = title.includes('✓') ? green(bold(title)) : title.includes('⊘') ? yellow(title)
182
+ : title.includes(':') && /^\d+ tools? · /.test(title) ? red(title) : accent(bold(title));
183
+ const lines = [dim(' ') + styled, ...this.previewLines.map((line) =>
184
+ dim(' │ ') + (line.startsWith('+ ') ? green(line) : line.startsWith('− ') ? red(line) : dim(line)))];
185
+ const card = lines.join('\n') + '\n';
186
+ if (this.cardStart && this.cardEnd) {
187
+ this.viewport.replace(this.cardStart, this.cardEnd, card, columns);
188
+ this.paintChat();
189
+ } else {
190
+ this.endTurn();
191
+ this.cardStart = this.viewport.position();
192
+ this.writeChat(card);
193
+ this.cardEnd = this.viewport.position();
194
+ }
195
+ }
196
+
197
+ activity(label: string, text: string): boolean {
198
+ if (!this.dock) return false;
199
+ if (label === 'thinking') {
200
+ this.thinkingText = text === label ? '● thinking' : text;
201
+ this.drawThinking();
202
+ this.drawDock();
203
+ } else this.setAgentActivity(this.previewTitle ? `${text === label ? '●' : text.split(' ')[0]} ${this.previewTitle}` : text);
204
+ return true;
205
+ }
206
+
207
+ stopActivity(note: string): void {
208
+ if (!this.agentActivity || this.agentActivity.includes('✓') || this.agentActivity.includes('⊘')) return;
209
+ this.setAgentActivity(`⊘ ${this.previewTitle || 'tool'} · ${note}`);
210
+ if (this.cardStart && this.cardEnd) this.viewport.release(this.cardStart, this.cardEnd);
211
+ this.cardStart = this.cardEnd = null;
212
+ }
213
+
214
+ finishActivity(label: string, final: string): void {
215
+ if (label === 'thinking') {
216
+ this.clearThinking();
217
+ if (this.dock) this.drawDock();
218
+ } else {
219
+ if (final) this.toolCount++;
220
+ const result = this.previewTitle ? clean(final).replace(/\b(write_file|edit_file|make_dir|copy_path|move_path|remove_path|restore_file|run_command)\b/, () => this.previewTitle) : clean(final);
221
+ this.setAgentActivity(final ? `${this.toolCount} tool${this.toolCount === 1 ? '' : 's'} · ${result}` : '');
222
+ if (this.cardStart && this.cardEnd) this.viewport.release(this.cardStart, this.cardEnd);
223
+ this.cardStart = this.cardEnd = null;
224
+ }
225
+ }
226
+
227
+ commandNotice(text: string): void {
228
+ if (!this.dock) { this.info(text); return; }
229
+ this.notice = text;
230
+ this.drawDock();
231
+ }
232
+
233
+ openApproval(title: string, lines: string[], hint: string): void {
234
+ this.approvalTitle = clean(title);
235
+ this.approvalLines = lines.map(clean);
236
+ this.approvalHint = clean(hint);
237
+ this.approvalChoice = '';
238
+ this.approvalCursor = 0;
239
+ this.approvalTop = 0;
240
+ if (!this.dock) return;
241
+ this.reflow();
242
+ this.drawDock();
243
+ }
244
+
245
+ hasApproval(): boolean { return this.dock && !!this.approvalTitle; }
246
+
247
+ setApprovalChoice(text: string, cursor: number): boolean {
248
+ if (!this.hasApproval()) return false;
249
+ this.approvalChoice = clean(text);
250
+ this.approvalCursor = cursor;
251
+ this.drawDock();
252
+ return true;
253
+ }
254
+
255
+ scrollApproval(delta: number): void {
256
+ if (!this.hasApproval()) return;
257
+ const visible = this.approvalPageSize();
258
+ this.approvalTop = Math.max(0, Math.min(Math.max(0, this.approvalRows().length - visible), this.approvalTop + delta));
259
+ this.drawDock();
260
+ }
261
+
262
+ closeApproval(): void {
263
+ if (!this.approvalTitle) return;
264
+ this.approvalTitle = '';
265
+ this.approvalLines = [];
266
+ this.approvalHint = '';
267
+ this.approvalChoice = '';
268
+ this.approvalCursor = 0;
269
+ this.approvalTop = 0;
270
+ if (!this.dock) return;
271
+ this.reflow();
272
+ this.drawDock();
273
+ }
274
+
275
+ panel(lines: string[], cancel?: () => void): boolean {
276
+ if (!this.dock) return false;
277
+ if (cancel) this.panelCancel = cancel;
278
+ const columns = Math.max(4, (process.stdout.columns || 80) - 1);
279
+ this.panelLines = lines.flatMap((line) => {
280
+ const rows: string[] = [];
281
+ let text = clean(line);
282
+ while (width(text) > columns) {
283
+ const part = fit(text, columns);
284
+ rows.push(part);
285
+ text = text.slice(part.length);
286
+ }
287
+ rows.push(text);
288
+ return rows;
289
+ });
290
+ this.reflow();
291
+ this.drawDock();
292
+ return true;
293
+ }
294
+
295
+ scrollPanel(delta: number): void {
296
+ if (!this.panelLines) return;
297
+ this.panelTop = Math.max(0, Math.min(this.panelLines.length - 1, this.panelTop + delta));
298
+ this.drawDock();
299
+ }
300
+
301
+ closePanel(): void {
302
+ if (!this.panelLines) return;
303
+ this.panelLines = null;
304
+ this.panelCancel = null;
305
+ this.panelTop = 0;
306
+ this.reflow();
307
+ this.drawDock();
308
+ }
309
+
310
+ welcome(model: string, cwd: string): void {
311
+ if (!process.stdout.isTTY) {
312
+ this.say(`sub · model ${model} · cwd ${cwd} · type / for commands`);
313
+ return;
314
+ }
315
+ this.model = model;
316
+ this.openDock();
317
+ this.say(` ${accent(bold('sub'))} ${dim(cwd)}`);
318
+ this.say(` ${dim('What are we building? / commands · ctrl+v image')}`);
319
+ this.say('');
320
+ }
321
+
322
+ /** Reserve five bottom rows; keep shell history in scrollback (no alternate screen). */
323
+ private openDock(): void {
324
+ if (this.dock || !process.stdin.isTTY || !process.stdout.isTTY || (process.stdout.rows || 24) < 10) return;
325
+ this.dock = true;
326
+ this.dockRows.clear();
327
+ process.stdout.write('\n'.repeat(process.stdout.rows || 24));
328
+ this.chatRows = (process.stdout.rows || 24) - this.dockHeight();
329
+ this.chatRow = 1;
330
+ this.chatColumn = 1;
331
+ process.stdout.write(`\x1b[?1000h\x1b[?1006h\x1b[1;${this.chatRows}r\x1b[1;1H\x1b7`);
332
+ process.stdout.off('resize', this.onResize);
333
+ process.stdout.on('resize', this.onResize);
334
+ process.once('exit', this.onExit);
335
+ this.drawDock();
336
+ }
337
+
338
+ closeDock(): void {
339
+ process.stdout.off('resize', this.onResize);
340
+ process.off('exit', this.onExit);
341
+ if (!this.dock) return;
342
+ this.clearThinking();
343
+ this.dock = false;
344
+ this.dockRows.clear();
345
+ process.stdout.write(`\x1b[?1000l\x1b[?1006l\x1b[${this.chatRows + 1};1H\x1b[J\x1b[r\x1b[${process.stdout.rows || 24};1H\x1b[?25h`);
346
+ this.menuItems = null;
347
+ this.menuCancel = null;
348
+ this.panelLines = null;
349
+ this.panelCancel = null;
350
+ }
351
+
352
+ private onExit = (): void => this.closeDock();
353
+ private onResize = (): void => {
354
+ this.dockRows.clear();
355
+ if ((process.stdout.rows || 24) < 10) {
356
+ if (this.dock) {
357
+ const cancel = this.menuCancel ?? this.panelCancel;
358
+ this.closeDock();
359
+ cancel?.();
360
+ process.stdout.on('resize', this.onResize);
361
+ process.once('exit', this.onExit);
362
+ }
363
+ this.composer(this.model, this.prompt, this.text, this.cursor);
364
+ return;
365
+ }
366
+ if (!this.dock) {
367
+ this.clearComposer();
368
+ this.openDock();
369
+ this.paintChat();
370
+ return;
371
+ }
372
+ this.reflow();
373
+ this.paintChat();
374
+ };
375
+
376
+ private menuHeight(): number {
377
+ const lines = this.menuItems ?? this.panelLines;
378
+ return lines ? Math.min(lines.length, 8, Math.max(1, (process.stdout.rows || 24) - this.dockHeight() - 3)) + 1 : 0;
379
+ }
380
+
381
+ /** Scroll output clear of a newly opened popup before reducing its region. */
382
+ private reflow(): void {
383
+ const next = Math.max(2, (process.stdout.rows || 24) - this.dockHeight() - this.menuHeight());
384
+ if (next === this.chatRows) return; // width-only resize preserves the saved cursor
385
+ this.dockRows.clear();
386
+ process.stdout.write(`\x1b[${Math.min(this.chatRows + 1, process.stdout.rows || 24)};1H\x1b[J`);
387
+ if (this.chatRow > next) {
388
+ const shift = this.chatRow - next;
389
+ process.stdout.write(`\x1b[${this.chatRows};1H` + '\n'.repeat(shift));
390
+ this.chatRow -= shift;
391
+ }
392
+ this.chatRows = next;
393
+ this.chatRow = Math.min(next, this.chatRow);
394
+ this.chatColumn = Math.min(process.stdout.columns || 80, this.chatColumn);
395
+ process.stdout.write(`\x1b[1;${next}r\x1b[${this.chatRow};${this.chatColumn}H\x1b7`);
396
+ this.paintChat();
397
+ }
398
+
399
+ setStatus(status: string): void {
400
+ if (status !== 'ready') this.notice = '';
401
+ if (this.status === status) return;
402
+ this.status = status;
403
+ if (this.dock) this.drawDock();
404
+ }
405
+ setMode(mode: string): void {
406
+ this.mode = mode;
407
+ if (this.dock) this.drawDock();
408
+ }
409
+ setEffort(effort: string): void {
410
+ this.effort = effort;
411
+ if (this.dock) this.drawDock();
412
+ }
413
+ setSteering(queued: number | null, messages: string[] = []): void {
414
+ const oldHeight = this.dockHeight();
415
+ this.queuedSteering = queued;
416
+ this.steeringMessages = queued ? messages.map(clean) : [];
417
+ if (this.dock) {
418
+ if (oldHeight !== this.dockHeight()) this.reflow();
419
+ this.drawDock();
420
+ }
421
+ }
422
+ setAttachments(names: string[]): void {
423
+ this.attachmentNames = names.map(clean);
424
+ if (this.dock) this.drawDock();
425
+ }
426
+ setUsage(usage: string, limits = ''): void {
427
+ const oldHeight = this.dockHeight();
428
+ this.usage = usage;
429
+ this.limits = limits;
430
+ if (this.dock) {
431
+ if (oldHeight !== this.dockHeight()) this.reflow();
432
+ this.drawDock();
433
+ }
434
+ }
435
+
436
+ composer(model: string, prompt: string, text: string, cursor: number): void {
437
+ if (text !== this.text && text) this.notice = '';
438
+ this.model = model;
439
+ this.prompt = prompt;
440
+ this.text = text;
441
+ this.cursor = cursor;
442
+ if (this.dock) {
443
+ this.drawDock();
444
+ return;
445
+ }
446
+ this.clearComposer();
447
+ const columns = Math.max(8, (process.stdout.columns || 80) - 1);
448
+ const status = fit(`─ ${model} `, columns);
449
+ this.say(dim(status + '─'.repeat(Math.max(0, columns - width(status)))));
450
+ const available = Math.max(1, columns - 2);
451
+ const start = Math.max(0, cursor - available + 1);
452
+ const visible = text.slice(start, start + available);
453
+ this.line(prompt, visible, cursor - start);
454
+ this.composerVisible = true;
455
+ }
456
+
457
+ clearComposer(): void {
458
+ if (this.dock) {
459
+ this.text = '';
460
+ this.cursor = 0;
461
+ this.drawDock();
462
+ return;
463
+ }
464
+ if (!this.composerVisible) return;
465
+ process.stdout.write('\r\x1b[1A\x1b[J');
466
+ this.composerVisible = false;
467
+ this.lineOpen = false;
468
+ }
469
+
470
+ submitInput(prompt: string, text: string): void {
471
+ if (this.viewport.offset) this.scrollChat(-Infinity);
472
+ this.clearComposer();
473
+ this.say(prompt + text);
474
+ }
475
+
476
+ /** Command controls live below the input, outside the scrolling conversation. */
477
+ menu(items: string[], index: number, top: number, cancel?: () => void, hint = '↑/↓ move · enter select · esc/ctrl+c cancel'): boolean {
478
+ if (!this.dock) return false;
479
+ this.menuItems = items;
480
+ this.menuCancel = cancel ?? null;
481
+ this.menuHint = hint;
482
+ this.menuIndex = index;
483
+ this.menuTop = top;
484
+ this.reflow();
485
+ this.drawDock();
486
+ return true;
487
+ }
488
+ closeMenu(): void {
489
+ if (!this.dock || !this.menuItems) return;
490
+ this.dockRows.clear();
491
+ process.stdout.write(`\x1b[${this.chatRows + 1};1H\x1b[J`);
492
+ this.menuItems = null;
493
+ this.menuCancel = null;
494
+ this.reflow();
495
+ this.drawDock();
496
+ }
497
+
498
+ private drawDock(): void {
499
+ const columns = Math.max(4, (process.stdout.columns || 80) - 1);
500
+ let output = '';
501
+ const put = (row: number, text: string): void => {
502
+ if (this.dockRows.get(row) === text) return;
503
+ this.dockRows.set(row, text);
504
+ output += `\x1b[${row};1H` + text + '\x1b[K';
505
+ };
506
+ let row = this.chatRows + 1;
507
+ let approvalInputRow = 0;
508
+ if (this.approvalTitle) {
509
+ const available = Math.max(1, columns - 4);
510
+ const title = fit(` ${this.approvalTitle} `, Math.max(1, columns - 4));
511
+ put(row++, yellow('╭─') + yellow(bold(title)) + dim('─'.repeat(Math.max(0, columns - width(title) - 3))) + yellow('╮'));
512
+ const body = (text: string, marker = '│'): string => yellow('│ ') + text + ' '.repeat(Math.max(0, columns - width(text) - 3)) + yellow(marker);
513
+ const details = this.approvalRows();
514
+ const pageSize = this.approvalPageSize();
515
+ this.approvalTop = Math.min(this.approvalTop, Math.max(0, details.length - pageSize));
516
+ const visible = details.slice(this.approvalTop, this.approvalTop + pageSize);
517
+ for (let i = 0; i < visible.length; i++) {
518
+ const above = i === 0 && this.approvalTop > 0;
519
+ const below = i === visible.length - 1 && this.approvalTop + pageSize < details.length;
520
+ const marker = above && below ? '↕' : above ? '↑' : below ? '↓' : '│';
521
+ put(row++, body(fit(visible[i], available), marker));
522
+ }
523
+ if (this.approvalHeight() > 3) {
524
+ const hint = details.length > pageSize ? `${this.approvalHint} · PgUp/PgDn details` : this.approvalHint;
525
+ put(row++, body(dim(fit(hint, available))));
526
+ }
527
+ approvalInputRow = row;
528
+ const choice = fit(this.approvalChoice, Math.max(1, columns - 6));
529
+ put(row++, yellow('╰─ ') + accent('› ') + choice + ' '.repeat(Math.max(0, columns - width(choice) - 6)) + yellow('╯'));
530
+ }
531
+ const pendingRows = this.steeringHeight();
532
+ if (pendingRows) {
533
+ const count = pendingRows === 1 ? 1 : pendingRows - 1;
534
+ for (const message of this.steeringMessages.slice(-count)) {
535
+ const raw = '› ' + message;
536
+ const text = width(raw) > columns ? fit(raw, columns - 1) + '…' : raw;
537
+ put(row++, wrap(text + ' '.repeat(Math.max(0, columns - width(text))), '48;2;56;56;56;38;2;160;160;160'));
538
+ }
539
+ if (pendingRows > 1) {
540
+ const hidden = this.steeringMessages.length - count;
541
+ put(row++, dim(fit(` ${hidden ? `${hidden} earlier · ` : ''}pending · sent at next model call`, columns)));
542
+ }
543
+ }
544
+ const absolute = this.mode === 'ABSOLUTE — NO APPROVALS';
545
+ const label = fit(absolute ? ` ABSOLUTE — NO APPROVALS · ${this.model} ─` : ` ${this.model} ─`, Math.max(0, columns - 2));
546
+ put(row++, dim('─'.repeat(Math.max(1, columns - width(label)))) + (absolute ? red(bold(label)) : accent(bold(label))));
547
+ const inputRow = row;
548
+ const prefix = fit(this.prompt, Math.max(2, columns - 8));
549
+ const available = Math.max(1, columns - width(prefix));
550
+ // Slice by cells around the caret, preserving the editor's UTF-16 cursor contract.
551
+ let start = this.cursor;
552
+ let before = '';
553
+ for (const c of [...this.text.slice(0, this.cursor)].reverse()) {
554
+ if (width(c + before) >= available) break;
555
+ before = c + before;
556
+ start -= c.length;
557
+ }
558
+ const visible = fit(this.text.slice(start), available);
559
+ put(row++, violet(prefix) + visible);
560
+ put(row++, dim('─'.repeat(columns)));
561
+ if (this.menuItems) {
562
+ const count = this.menuHeight() - 1;
563
+ if (this.menuIndex < this.menuTop) this.menuTop = this.menuIndex;
564
+ if (this.menuIndex >= this.menuTop + count) this.menuTop = this.menuIndex - count + 1;
565
+ for (let i = 0; i < count; i++) {
566
+ const index = this.menuTop + i;
567
+ const label = fit(this.menuItems[index] ?? '', columns - 2);
568
+ const styled = label.replace(/^(\/\w+)(\s+)(.*)$/, (_, command, gap, description) => accent(command) + gap + dim(description));
569
+ put(row++, index === this.menuIndex ? violet('› ') + accent(bold(label)) : ' ' + styled);
570
+ }
571
+ put(row++, dim(fit(this.menuHint, columns)));
572
+ } else if (this.panelLines) {
573
+ const count = this.menuHeight() - 1;
574
+ this.panelTop = Math.min(this.panelTop, Math.max(0, this.panelLines.length - count));
575
+ for (let i = 0; i < count; i++) {
576
+ const index = this.panelTop + i;
577
+ const text = fit(this.panelLines[index] ?? '', columns);
578
+ put(row++, index === 0 ? accent(bold(text)) : text.startsWith(' ') ? text : usageStyle(text));
579
+ }
580
+ put(row++, dim(fit('↑/↓ scroll · r refresh · enter/esc close', columns)));
581
+ }
582
+ if (this.menuItems || this.panelLines) {
583
+ const caretRow = approvalInputRow || inputRow;
584
+ const caretColumn = approvalInputRow ? Math.min(columns, 5 + Math.min(this.approvalCursor, width(this.approvalChoice)) + 1)
585
+ : Math.min(columns, width(prefix) + width(before) + 1);
586
+ process.stdout.write(output + `\x1b[${caretRow};${caretColumn}H`);
587
+ return;
588
+ }
589
+ const footerStatus = this.status.startsWith('thinking') ? 'ctrl+c stop' : this.status;
590
+ const activity = this.viewport.offset ? 'history · PgDn to live' : footerStatus;
591
+ const attachments = this.attachmentNames.length ? `${this.attachmentNames.length} image${this.attachmentNames.length === 1 ? '' : 's'} attached · ${this.attachmentNames.join(' · ')}` : '';
592
+ const steering = this.queuedSteering === null ? '' : `${this.queuedSteering ? `${this.queuedSteering} steering queued` : 'Enter to steer'} · ${activity}`;
593
+ const status = this.notice || (this.commandMode ? this.status : attachments || steering || `${this.mode} · effort ${this.effort} · ${activity}`);
594
+ const statusText = fit(status, columns);
595
+ put(row++, this.notice || attachments && !this.commandMode ? accent(statusText) : statusText
596
+ .replace(this.mode, absolute ? red(bold(this.mode)) : this.mode === 'guarded auto' || this.mode === 'auto mode on' ? yellow(this.mode) : dim(this.mode))
597
+ .replace(`effort ${this.effort}`, violet(`effort ${this.effort}`))
598
+ .replace(/\d+ steering queued|Enter to steer/, (label) => violet(label))
599
+ .replace(this.status, this.status === 'ready' ? green(this.status) : yellow(this.status))
600
+ .replace(/ · /g, dim(' · ')));
601
+ if (!this.commandMode) {
602
+ put(row++, usageStyle(fit(this.usage, columns)));
603
+ if (this.limits) put(row, usageStyle(fit(this.limits, columns)));
604
+ }
605
+ const caretRow = approvalInputRow || inputRow;
606
+ const caretColumn = approvalInputRow ? Math.min(columns, 5 + Math.min(this.approvalCursor, width(this.approvalChoice)) + 1)
607
+ : Math.min(columns, width(prefix) + width(before) + 1);
608
+ process.stdout.write(output + `\x1b[${caretRow};${caretColumn}H`);
609
+ }
610
+
611
+ scrollChat(delta: number): void {
612
+ if (!this.dock) return;
613
+ this.viewport.scroll(delta, process.stdout.columns || 80, this.chatRows);
614
+ this.paintChat();
615
+ }
616
+
617
+ private drawThinking(): void {
618
+ if (!this.dock || this.viewport.offset || !this.thinkingText || this.chatColumn !== 1) return;
619
+ const columns = Math.max(1, (process.stdout.columns || 80) - 1);
620
+ const text = fit(this.thinkingText, columns);
621
+ const space = text.indexOf(' ');
622
+ const styled = space < 0 ? dim(text) : violet(text.slice(0, space)) + dim(text.slice(space));
623
+ // Transient overlay: never enters history or consumes a line on each frame.
624
+ process.stdout.write(`\x1b[${this.chatRow};1H\x1b[K` + styled);
625
+ }
626
+
627
+ private clearThinking(): void {
628
+ if (this.thinkingText && this.dock && !this.viewport.offset && this.chatColumn === 1) {
629
+ process.stdout.write(`\x1b[${this.chatRow};1H\x1b[K`);
630
+ }
631
+ this.thinkingText = '';
632
+ }
633
+
634
+ private paintChat(): void {
635
+ if (!this.dock) return;
636
+ const columns = process.stdout.columns || 80;
637
+ const lines = this.viewport.visible(columns, this.chatRows);
638
+ for (let row = 1; row <= this.chatRows; row++)
639
+ process.stdout.write(`\x1b[${row};1H\x1b[2K` + (lines[row - 1] ?? ''));
640
+ const caret = this.viewport.caret(columns, this.chatRows);
641
+ this.chatRow = caret.row;
642
+ this.chatColumn = caret.column;
643
+ this.restoredWrap = caret.column > columns;
644
+ process.stdout.write(`\x1b[${this.chatRow};${Math.min(columns, this.chatColumn)}H\x1b7`);
645
+ this.drawThinking();
646
+ this.drawDock();
647
+ }
648
+
649
+ chatPosition(): ChatPosition { return this.viewport.position(); }
650
+
651
+ removeChat(start: ChatPosition, end: ChatPosition): void {
652
+ this.clearThinking();
653
+ this.viewport.remove(start, end);
654
+ this.viewport.release(start, end);
655
+ if (this.dock) this.paintChat();
656
+ }
657
+
658
+ clearChat(): void {
659
+ this.clearThinking();
660
+ this.cardStart = this.cardEnd = null;
661
+ this.agentActivity = '';
662
+ this.viewport.clear();
663
+ if (!this.dock) return;
664
+ for (let row = 1; row <= this.chatRows; row++) process.stdout.write(`\x1b[${row};1H\x1b[2K`);
665
+ this.chatRow = this.chatColumn = 1;
666
+ this.restoredWrap = false;
667
+ this.lineOpen = false;
668
+ process.stdout.write('\x1b[1;1H\x1b7');
669
+ this.drawDock();
670
+ }
671
+
672
+ /** DEC save/restore tracks the chat cursor independently of the input caret. */
673
+ private writeChat(text: string): void {
674
+ this.clearThinking();
675
+ this.viewport.append(text, process.stdout.columns || 80);
676
+ if (this.dock && this.viewport.offset) {
677
+ this.paintChat();
678
+ return;
679
+ }
680
+ if (!this.dock) {
681
+ process.stdout.write(text);
682
+ return;
683
+ }
684
+ const first = stripVTControlCharacters(text)[0];
685
+ const wrap = this.restoredWrap && first && first !== '\n' && first !== '\r' ? '\r\n' : '';
686
+ if (first) this.restoredWrap = false;
687
+ process.stdout.write('\x1b8' + wrap + text + '\x1b7');
688
+ const columns = process.stdout.columns || 80;
689
+ for (const c of stripVTControlCharacters(wrap + text)) {
690
+ if (c === '\r') this.chatColumn = 1;
691
+ else if (c === '\n') { this.chatRow = Math.min(this.chatRows, this.chatRow + 1); this.chatColumn = 1; }
692
+ else if (c >= ' ') {
693
+ if (this.chatColumn > columns) { this.chatRow = Math.min(this.chatRows, this.chatRow + 1); this.chatColumn = 1; }
694
+ this.chatColumn += cells(c);
695
+ }
696
+ }
697
+ this.drawDock();
698
+ }
699
+
700
+ streamText(text: string): void {
701
+ this.writeChat(text);
702
+ this.lineOpen = true;
703
+ }
704
+ streamReasoning(text: string): void {
705
+ this.writeChat(dim(text));
706
+ this.lineOpen = true;
707
+ }
708
+ endTurn(): void {
709
+ if (this.lineOpen) this.writeChat('\n');
710
+ this.lineOpen = false;
711
+ }
712
+ clearLine(): void {
713
+ if (!this.dock) this.clearComposer();
714
+ this.writeChat('\r\x1b[K');
715
+ this.lineOpen = false;
716
+ }
717
+ rewrite(text: string): void {
718
+ this.clearLine();
719
+ this.writeChat(text);
720
+ this.lineOpen = !text.endsWith('\n');
721
+ }
722
+ line(prompt: string, text: string, cursor: number): void {
723
+ if (this.dock) {
724
+ this.prompt = prompt;
725
+ this.text = text;
726
+ this.cursor = cursor;
727
+ this.drawDock();
728
+ return;
729
+ }
730
+ this.clearLine();
731
+ process.stdout.write(prompt + text);
732
+ const back = text.length - cursor;
733
+ if (back > 0) process.stdout.write(`\x1b[${back}D`);
734
+ }
735
+ say(line: string): void {
736
+ if (!this.dock) this.clearComposer();
737
+ this.writeChat(line + '\n');
738
+ this.lineOpen = false;
739
+ }
740
+ warn(line: string): void { this.say(yellow('! ') + line); }
741
+ error(line: string): void { this.say(red('× ') + line); }
742
+ info(line: string): void { this.say(dim('· ') + line); }
743
+ spinner(text: string): Spinner { return new Spinner(text, this); }
744
+ }
745
+
746
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
747
+
748
+ /** Delayed activity indicator; never animates in pipes or under NO_COLOR. */
749
+ export class Spinner {
750
+ private text: string;
751
+ private sink: UiImpl;
752
+ private timer: NodeJS.Timeout | null = null;
753
+ private frame = 0;
754
+ private shown = false;
755
+ private done = false;
756
+ private live = false;
757
+
758
+ constructor(text: string, sink: UiImpl) {
759
+ this.text = text;
760
+ this.sink = sink;
761
+ }
762
+ start(delayMs = 300): this {
763
+ this.live = this.sink.activity(this.text, this.text);
764
+ if (!animated()) {
765
+ if (this.text && !this.live) this.sink.info(this.text);
766
+ return this;
767
+ }
768
+ this.arm(delayMs);
769
+ return this;
770
+ }
771
+ pause(): void {
772
+ if (this.timer) {
773
+ clearTimeout(this.timer);
774
+ clearInterval(this.timer);
775
+ this.timer = null;
776
+ }
777
+ }
778
+ resume(): void {
779
+ if (!animated() || this.done || this.timer) return;
780
+ this.arm(150);
781
+ }
782
+ private arm(delayMs: number): void {
783
+ this.timer = setTimeout(() => {
784
+ this.shown = true;
785
+ this.tick();
786
+ this.timer = setInterval(() => this.tick(), 100);
787
+ }, delayMs);
788
+ }
789
+ private tick(): void {
790
+ const text = SPINNER_FRAMES[this.frame++ % SPINNER_FRAMES.length] + ' ' + this.text;
791
+ if (this.live) this.sink.activity(this.text, text);
792
+ else this.sink.rewrite(text);
793
+ }
794
+ stop(final: string, keepActivity = false): void {
795
+ if (this.done) return;
796
+ this.done = true;
797
+ this.pause();
798
+ if (keepActivity) return;
799
+ if (this.live) {
800
+ this.sink.finishActivity(this.text, final);
801
+ return;
802
+ }
803
+ if (!animated()) {
804
+ if (final !== '') this.sink.say(final);
805
+ return;
806
+ }
807
+ if (final === '') this.sink.clearLine();
808
+ else if (this.shown) this.sink.rewrite(final + '\n');
809
+ else this.sink.say(final);
810
+ }
811
+ }
812
+
813
+ export const ui: UiImpl = new UiImpl();