@hmharness/cli 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.
package/dist/tui.js ADDED
@@ -0,0 +1,1085 @@
1
+ /**
2
+ * @hmharness/cli - tui (fullscreen, Claude-Code / dsh-TUI style)
3
+ *
4
+ * ┌ header: logo · model · cwd · skills · spinner/status
5
+ * ├ transcript viewport (scrollable, auto-follow)
6
+ * ├ approval card (when a gated tool awaits a decision)
7
+ * ├ input box: bordered, single line + block caret, ↑/↓ history
8
+ * └ status bar: key hints · tokens · scroll position
9
+ *
10
+ * Zero dependencies: raw-mode stdin + ANSI. CJK-aware widths (wcwidth-lite).
11
+ * Not a TTY? Prints a pointer to the plain REPL instead.
12
+ */
13
+ import { stdin, stdout } from 'node:process';
14
+ import { basename } from 'node:path';
15
+ import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders } from '@hmharness/kernel';
16
+ import { listDrafts, listSkills, runBench, runEvolution } from '@hmharness/evolution';
17
+ import { buildRegistry, runAgentTask, strings } from '@hmharness/agent';
18
+ import { ensureWebDaemon, DEFAULT_WEB_PORT } from "./web-daemon.js";
19
+ const RESET = '\x1b[0m';
20
+ const DIM = (s) => `\x1b[2m${s}${RESET}`;
21
+ const BOLD = (s) => `\x1b[1m${s}${RESET}`;
22
+ const CYAN = (s) => `\x1b[36m${s}${RESET}`;
23
+ const GREEN = (s) => `\x1b[32m${s}${RESET}`;
24
+ const YELLOW = (s) => `\x1b[33m${s}${RESET}`;
25
+ const RED = (s) => `\x1b[31m${s}${RESET}`;
26
+ /* ---------------- text-width + wrapping (CJK aware) ---------------- */
27
+ function cw(ch) {
28
+ const c = ch.codePointAt(0) ?? 0;
29
+ if ((c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
30
+ (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) ||
31
+ (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x20000 && c <= 0x3fffd))
32
+ return 2;
33
+ return 1;
34
+ }
35
+ function strWidth(s) {
36
+ let w = 0;
37
+ for (const ch of s)
38
+ w += cw(ch);
39
+ return w;
40
+ }
41
+ /** strip ANSI so width math is done on visible text */
42
+ function stripAnsi(s) {
43
+ return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
44
+ }
45
+ function wrapTo(s, width, indent = 0) {
46
+ const out = [];
47
+ let line = '';
48
+ let w = 0;
49
+ const pad = ' '.repeat(indent);
50
+ for (const ch of s) {
51
+ if (ch === '\n' || w + cw(ch) > width) {
52
+ out.push(line);
53
+ line = pad;
54
+ w = indent;
55
+ if (ch === '\n')
56
+ continue;
57
+ }
58
+ line += ch;
59
+ w += cw(ch);
60
+ }
61
+ out.push(line);
62
+ return out;
63
+ }
64
+ function truncateTo(s, width) {
65
+ const plain = stripAnsi(s);
66
+ if (strWidth(plain) <= width)
67
+ return s;
68
+ // truncate on the plain text, keep it simple (drop styling precision)
69
+ let w = 0;
70
+ let out = '';
71
+ for (const ch of plain) {
72
+ if (w + cw(ch) > width - 1)
73
+ return out + '…';
74
+ out += ch;
75
+ w += cw(ch);
76
+ }
77
+ return out;
78
+ }
79
+ /* ---------------- slash commands + mouse wheel (pure, testable) ---------------- */
80
+ /** desc keys index into Strings (agent i18n); matched by name at runtime. */
81
+ export const COMMANDS = [
82
+ { name: '/help', key: 'cmdHelp' },
83
+ { name: '/tools', key: 'cmdTools' },
84
+ { name: '/skills', key: 'cmdSkills' },
85
+ { name: '/model', key: 'cmdModel' },
86
+ { name: '/lang', key: 'cmdLang' },
87
+ { name: '/yolo', key: 'cmdYolo' },
88
+ { name: '/providers', key: 'cmdProviders' },
89
+ { name: '/ops', key: 'cmdOps' },
90
+ { name: '/ops scan', key: 'cmdOpsScan' },
91
+ { name: '/bench', key: 'cmdBench' },
92
+ { name: '/evolve', key: 'cmdEvolve' },
93
+ { name: '/mcp', key: 'cmdMcp' },
94
+ { name: '/status', key: 'cmdStatus' },
95
+ { name: '/clear', key: 'cmdClear' },
96
+ { name: '/web', key: 'cmdWeb' },
97
+ { name: '/exit', key: 'cmdExit' },
98
+ ];
99
+ /** Commands whose name starts with the input (input must start with '/'). */
100
+ export function matchCommands(input) {
101
+ if (!input.startsWith('/'))
102
+ return [];
103
+ const q = input.toLowerCase();
104
+ return COMMANDS.filter((c) => c.name.startsWith(q));
105
+ }
106
+ /** `/lang` target resolver: explicit zh/en wins, a bare `/lang` toggles. */
107
+ export function nextLocale(current, arg) {
108
+ const a = arg.trim().toLowerCase();
109
+ if (a === 'zh' || a === 'en')
110
+ return a;
111
+ return current === 'en' ? 'zh' : 'en';
112
+ }
113
+ /**
114
+ * SGR mouse wheel decoding: '\x1b[<64;COL;ROWM' is wheel-up (-1), 65 is
115
+ * wheel-down (+1), anything else (clicks, drags, plain keys) is 0.
116
+ */
117
+ export function parseWheel(data) {
118
+ const m = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]/);
119
+ if (!m)
120
+ return 0;
121
+ const btn = Number(m[1]);
122
+ if (btn === 64)
123
+ return -1;
124
+ if (btn === 65)
125
+ return 1;
126
+ return 0;
127
+ }
128
+ export class TuiRuntime {
129
+ entries = [];
130
+ dirty = true;
131
+ scrollFromBottom = 0;
132
+ input = '';
133
+ caret = 0;
134
+ history = [];
135
+ histIdx = -1;
136
+ busy = false;
137
+ spinnerFrame = 0;
138
+ cmdIdx = 0; // selected row in the slash palette (arrow keys)
139
+ spinnerTimer;
140
+ status = '';
141
+ approval = null;
142
+ approvalResolve = null;
143
+ running = true;
144
+ renderTimer;
145
+ exitResolve = null;
146
+ driver = null;
147
+ model = '';
148
+ cwdName = '';
149
+ skillCount = 0;
150
+ t = strings();
151
+ /** persistent header tag, e.g. the active approval mode (🔥 YOLO) */
152
+ modeTag = '';
153
+ /** rows for the `/model ` picker (configured providers first, set by driver) */
154
+ modelChoices = [];
155
+ /** Wheel/click handling: NO mouse reporting by default - select/copy
156
+ * always works and terminals translate the wheel to arrow keys on the
157
+ * alternate screen. Reporting turns on ONLY while a palette is open
158
+ * (modal: clicks choose rows, the wheel drives the selection) and turns
159
+ * off the moment it closes. (A permanent /mouse toggle existed for
160
+ * terminals without alt-screen wheel mapping; removed - it cost native
161
+ * selection full-time to fix a case this Windows/HarmonyOS-first tool
162
+ * does not target.) */
163
+ mouseReported = false;
164
+ /** screen row of each visible palette item (SGR click hit-testing); the
165
+ * render loop records row = frame.length (1-based) as it pushes rows */
166
+ paletteClickRows = [];
167
+ constructor() {
168
+ // ?1l forces DECCKM OFF so arrow keys arrive as CSI (\x1b[A) even if a
169
+ // previous program left the terminal in application cursor mode - in
170
+ // that mode arrows arrive as SS3 (\x1bOA) and would be silently dropped
171
+ stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[?1l');
172
+ stdin.setRawMode?.(true);
173
+ stdin.resume();
174
+ stdin.setEncoding('utf8');
175
+ stdin.on('data', (d) => this.onKey(d));
176
+ stdout.on('resize', () => { this.dirty = true; });
177
+ this.renderTimer = setInterval(() => this.render(), 90);
178
+ }
179
+ setModelChoices(list) {
180
+ this.modelChoices = list;
181
+ this.dirty = true;
182
+ }
183
+ /** Focus the /model picker (used by the bare `/model` command so the
184
+ * printed list is never a dead end - the live palette opens on it). */
185
+ openModelPicker() {
186
+ this.input = '/model ';
187
+ this.caret = this.input.length;
188
+ this.cmdIdx = 0;
189
+ this.dirty = true;
190
+ }
191
+ /** Run the highlighted palette row (shared by Enter and palette clicks);
192
+ * with no palette open it just submits the typed input. */
193
+ pickHighlighted() {
194
+ const hits = this.panelItems(this.input);
195
+ const pick = hits.length ? hits[Math.min(this.cmdIdx, hits.length - 1)].name : '';
196
+ if (pick) {
197
+ this.input = this.input.startsWith('/model') ? `/model ${pick} ` : pick + ' ';
198
+ this.caret = this.input.length;
199
+ this.cmdIdx = 0;
200
+ }
201
+ this.driver?.();
202
+ }
203
+ /** Introspection probe for headless tests: input line, palette rows, the
204
+ * highlighted row index, mouse-reporting state, clickable rows, the
205
+ * transcript text, and the last rendered frame (ANSI-stripped). */
206
+ paletteProbe() {
207
+ const rows = this.panelItems(this.input);
208
+ const lines = [];
209
+ for (const e of this.entries)
210
+ lines.push(...e.lines);
211
+ // replay one render into a capture so assertions see the real frame;
212
+ // dirty was already cleared by a prior tick, so force it
213
+ const f = [];
214
+ const realWrite = stdout.write.bind(stdout);
215
+ stdout.write = ((s) => { f.push(String(s)); return true; });
216
+ try {
217
+ this.dirty = true;
218
+ this.render();
219
+ }
220
+ finally {
221
+ stdout.write = realWrite;
222
+ }
223
+ return {
224
+ input: this.input,
225
+ rows: rows.map((r) => r.name),
226
+ selected: rows.length ? Math.min(this.cmdIdx, rows.length - 1) : -1,
227
+ mouse: this.mouseReported,
228
+ clickRows: this.paletteClickRows.map((p) => ({ ...p })),
229
+ transcript: lines.join('\n'),
230
+ // frame rows: split on the CUP positioning pairs, take the content
231
+ // halves by parity, strip ANSI. (A 'startsWith ESC' filter used to
232
+ // discard every styled row - i.e. exactly the interesting ones.)
233
+ frameText: (() => {
234
+ const parts = f.join('').split(/(\x1b\[\d+;1H\x1b\[2K)/);
235
+ const rows = [];
236
+ for (let i = 0; i < parts.length; i += 2) {
237
+ const row = stripAnsi(parts[i] ?? '').replace(/\x1b\[0J$/, '').trimEnd();
238
+ if (row)
239
+ rows.push(row);
240
+ }
241
+ return rows.join('\n');
242
+ })(),
243
+ };
244
+ }
245
+ setModeTag(tag) {
246
+ this.modeTag = tag;
247
+ this.dirty = true;
248
+ }
249
+ /** Reporting is on ONLY while a palette is open. The palette is a modal:
250
+ * while it shows, clicks choose its rows and the wheel drives its
251
+ * selection; drag-select resumes the moment it closes. */
252
+ syncMouseReporting() {
253
+ const want = this.panelItems(this.input).length > 0;
254
+ if (want === this.mouseReported)
255
+ return;
256
+ this.mouseReported = want;
257
+ stdout.write(want ? '\x1b[?1000h\x1b[?1006h' : '\x1b[?1000l\x1b[?1006l');
258
+ }
259
+ /** The palette data source: `/model ` opens the model picker, otherwise
260
+ * slash commands. Rows are {name, desc} so both share one renderer. */
261
+ panelItems(input) {
262
+ if (input === '/model' || input.startsWith('/model ')) {
263
+ const q = input.slice(6).trim().toLowerCase();
264
+ const configured = this.modelChoices;
265
+ const rest = PROVIDER_PRESETS
266
+ .filter((p) => !configured.some((c) => c.name === p.name))
267
+ .map((p) => ({ name: p.name, desc: `${p.model}${p.envVar ? ' · set ' + p.envVar : ' · local'}` }));
268
+ const all = [...configured, ...rest];
269
+ return q ? all.filter((i) => i.name.toLowerCase().startsWith(q)) : all;
270
+ }
271
+ return matchCommands(input).map((c) => ({ name: c.name, desc: String(this.t[c.key]) }));
272
+ }
273
+ configure(model, cwdName, skillCount, locale) {
274
+ this.model = model;
275
+ this.cwdName = cwdName;
276
+ this.skillCount = skillCount;
277
+ this.t = strings(locale);
278
+ this.dirty = true;
279
+ }
280
+ destroy() {
281
+ if (this.renderTimer)
282
+ clearInterval(this.renderTimer);
283
+ if (this.spinnerTimer)
284
+ clearInterval(this.spinnerTimer);
285
+ // ?1l restores default CSI cursor keys; reporting off whatever the
286
+ // modal state was
287
+ stdout.write((this.mouseReported ? '\x1b[?1000l\x1b[?1006l' : '') + '\x1b[?1l\x1b[?25h\x1b[?1049l');
288
+ stdin.setRawMode?.(false);
289
+ stdin.pause();
290
+ }
291
+ waitExit() {
292
+ return new Promise((resolve) => { this.exitResolve = resolve; });
293
+ }
294
+ quit() {
295
+ this.running = false;
296
+ this.exitResolve?.();
297
+ }
298
+ /* ---------------- content API ---------------- */
299
+ addText(text, style = 'plain') {
300
+ const paint = style === 'dim' ? DIM : style === 'err' ? RED : (s) => s;
301
+ const width = Math.max(20, (stdout.columns || 100) - 2);
302
+ this.entries.push({ lines: wrapTo(text, width).map((l) => paint(l)) });
303
+ this.scrollFromBottom = 0;
304
+ this.dirty = true;
305
+ }
306
+ startStream(kind) {
307
+ const width = Math.max(20, (stdout.columns || 100) - 2);
308
+ const lines = [];
309
+ let buf = kind === 'think' ? '∴ ' : '';
310
+ const entry = { lines };
311
+ const repaint = () => {
312
+ if (kind === 'say') {
313
+ const wrapped = wrapTo(buf, width);
314
+ lines.length = 0;
315
+ wrapped.forEach((l) => lines.push(l));
316
+ }
317
+ else {
318
+ // thinking stays FOLDED while streaming: one live line (what Claude
319
+ // Code shows), never the raw chain-of-thought - it is model-internal
320
+ // planning, not user-facing output, and it swamped the transcript
321
+ lines.length = 0;
322
+ const tail = buf.replace(/\s+/g, ' ').trim();
323
+ lines.push(DIM('∴ ' + this.t.thinking + (tail ? ' · ' + tail.slice(-width + 18) : '…')));
324
+ }
325
+ this.dirty = true;
326
+ };
327
+ repaint();
328
+ this.entries.push(entry);
329
+ this.scrollFromBottom = 0;
330
+ return (chunk) => {
331
+ buf += chunk;
332
+ repaint();
333
+ };
334
+ }
335
+ /** Collapse a streamed thinking block to its final folded summary line. */
336
+ foldThinking() {
337
+ for (let i = this.entries.length - 1; i >= 0; i--) {
338
+ const e = this.entries[i];
339
+ const isThinking = e.lines.length === 1 && /^\x1b\[2m∴ /.test(e.lines[0]);
340
+ if (isThinking) {
341
+ e.lines.length = 0;
342
+ e.lines.push(DIM('∴ ' + this.t.thought));
343
+ this.dirty = true;
344
+ return;
345
+ }
346
+ }
347
+ }
348
+ setBusy(busy, label = '') {
349
+ this.busy = busy;
350
+ if (label)
351
+ this.status = label;
352
+ if (busy && !this.spinnerTimer) {
353
+ this.spinnerTimer = setInterval(() => { this.spinnerFrame = (this.spinnerFrame + 1) % 10; this.dirty = true; }, 120);
354
+ }
355
+ else if (!busy && this.spinnerTimer) {
356
+ clearInterval(this.spinnerTimer);
357
+ this.spinnerTimer = undefined;
358
+ }
359
+ this.dirty = true;
360
+ }
361
+ setStatus(s) {
362
+ this.status = s;
363
+ this.dirty = true;
364
+ }
365
+ requestApproval(name, args) {
366
+ this.approval = { name, args };
367
+ this.dirty = true;
368
+ return new Promise((resolve) => { this.approvalResolve = resolve; });
369
+ }
370
+ consumeInput() {
371
+ const line = this.input;
372
+ if (line.trim())
373
+ this.history.unshift(line);
374
+ this.histIdx = -1;
375
+ this.input = '';
376
+ this.caret = 0;
377
+ this.dirty = true;
378
+ return line;
379
+ }
380
+ onSubmit(fn) {
381
+ this.driver = fn;
382
+ }
383
+ /* ---------------- keyboard ---------------- */
384
+ clearScreen() {
385
+ this.entries = [];
386
+ this.scrollFromBottom = 0;
387
+ this.status = '';
388
+ this.dirty = true;
389
+ }
390
+ totalLines() {
391
+ let n = 0;
392
+ for (const e of this.entries)
393
+ n += e.lines.length;
394
+ return n;
395
+ }
396
+ onKey(data) {
397
+ // SS3 application-mode arrows (\x1bOA…H): terminals left in DECCKM by a
398
+ // previous program send these; normalize to the CSI forms this UI
399
+ // matches so navigation never silently dies
400
+ if (/^\x1bO[A-H]$/.test(data))
401
+ data = '\x1b[' + data[2];
402
+ // wheel-only mouse routing: 64 = wheel-up, 65 = wheel-down. With
403
+ // button-event mode (1002) everything else - click, drag, release,
404
+ // motion - still belongs to the terminal's native selection.
405
+ const wheel = parseWheel(data);
406
+ if (wheel !== 0) {
407
+ // an open palette takes the wheel: it moves the selection (the list
408
+ // is what the user is driving), the transcript only scrolls when the
409
+ // palette is closed
410
+ const hits = this.panelItems(this.input);
411
+ if (hits.length) {
412
+ this.cmdIdx = Math.max(0, Math.min(hits.length - 1, this.cmdIdx + wheel));
413
+ this.dirty = true;
414
+ return;
415
+ }
416
+ // wheel-up (-1) moves the viewport UP, i.e. further from the bottom
417
+ this.scrollFromBottom = Math.max(0, Math.min(this.totalLines(), this.scrollFromBottom - wheel * 3));
418
+ this.dirty = true;
419
+ return;
420
+ }
421
+ // click-to-choose on the open palette (SGR button-0 press): the
422
+ // terminal-reported row maps 1:1 to the screen row render() recorded
423
+ // for that item, so a click is a row selection + confirm
424
+ if (this.paletteClickRows.length) {
425
+ const click = data.match(/^\x1b\[<0;\d+;(\d+)M$/);
426
+ if (click) {
427
+ const row = Number(click[1]);
428
+ const hitRow = this.paletteClickRows.find((p) => p.row === row);
429
+ if (hitRow) {
430
+ this.cmdIdx = hitRow.idx;
431
+ this.pickHighlighted();
432
+ return;
433
+ }
434
+ }
435
+ }
436
+ // swallow any other SGR mouse report that slips through so it never
437
+ // leaks into the input line as garbage
438
+ if (/^\x1b\[<\d+;\d+;\d+[Mm]/.test(data))
439
+ return;
440
+ if (this.approval) {
441
+ const grant = data === 'y' || data === 'Y' || data === '\r';
442
+ const deny = data === 'n' || data === 'N' || data === '\x1b' || data === '\x03';
443
+ const resolve = this.approvalResolve;
444
+ this.approval = null;
445
+ this.approvalResolve = null;
446
+ if (resolve)
447
+ resolve(grant && !deny ? true : deny ? false : true);
448
+ this.dirty = true;
449
+ return;
450
+ }
451
+ if (data === '\x03') {
452
+ if (!this.input)
453
+ this.quit();
454
+ else {
455
+ this.input = '';
456
+ this.caret = 0;
457
+ }
458
+ this.dirty = true;
459
+ return;
460
+ }
461
+ if (data === '\x1b') {
462
+ // Esc closes an open palette / discards the draft. Exact-match only:
463
+ // arrow sequences arrive as one chunk ('\x1b[A') and never match.
464
+ if (this.input) {
465
+ this.input = '';
466
+ this.caret = 0;
467
+ this.cmdIdx = 0;
468
+ this.dirty = true;
469
+ }
470
+ return;
471
+ }
472
+ if (data === '\r') {
473
+ // bare '/model' + Enter OPENS the picker instead of running row 0 -
474
+ // Claude Code's two-stage flow: Enter shows the dialog, arrows/wheel
475
+ // move, a second Enter confirms the highlighted row. The old behavior
476
+ // silently switched to the first model on the very first Enter.
477
+ if (this.input === '/model') {
478
+ this.openModelPicker();
479
+ return;
480
+ }
481
+ // palette open: Enter runs the highlighted row (a command, or a
482
+ // /model target), not the raw input; without a palette it submits
483
+ this.pickHighlighted();
484
+ return;
485
+ }
486
+ if (data === '\t') {
487
+ const hits = this.panelItems(this.input);
488
+ if (hits.length) {
489
+ this.input = this.input.startsWith('/model')
490
+ ? `/model ${hits[Math.min(this.cmdIdx, hits.length - 1)].name} `
491
+ : hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
492
+ this.caret = this.input.length;
493
+ this.cmdIdx = 0;
494
+ this.dirty = true;
495
+ }
496
+ return;
497
+ }
498
+ if (data === '\x7f' || data === '\b') {
499
+ if (this.caret > 0) {
500
+ this.input = this.input.slice(0, this.caret - 1) + this.input.slice(this.caret);
501
+ this.caret--;
502
+ }
503
+ this.cmdIdx = 0;
504
+ this.dirty = true;
505
+ return;
506
+ }
507
+ // ↑/↓: the terminal's wheel→arrow fallback (alt screen, no mouse
508
+ // capture) arrives here, so the arrows ARE the wheel in this mode:
509
+ // they scroll the transcript (clamped), never touching history. Input
510
+ // history stays on PgUp/PgDn-adjacent keys and re-typing; the command
511
+ // palette (when open) takes priority for selection.
512
+ if (data === '\x1b[A') {
513
+ const hits = this.panelItems(this.input);
514
+ if (hits.length) {
515
+ this.cmdIdx = Math.max(0, this.cmdIdx - 1);
516
+ this.dirty = true;
517
+ return;
518
+ }
519
+ if (this.scrollFromBottom < this.totalLines()) {
520
+ this.scrollFromBottom = Math.min(this.totalLines(), this.scrollFromBottom + 3);
521
+ this.dirty = true;
522
+ }
523
+ return;
524
+ }
525
+ if (data === '\x1b[B') {
526
+ const hits = this.panelItems(this.input);
527
+ if (hits.length) {
528
+ this.cmdIdx = Math.min(hits.length - 1, this.cmdIdx + 1);
529
+ this.dirty = true;
530
+ return;
531
+ }
532
+ if (this.scrollFromBottom > 0) {
533
+ this.scrollFromBottom = Math.max(0, this.scrollFromBottom - 3);
534
+ this.dirty = true;
535
+ }
536
+ return;
537
+ }
538
+ // history walk: Ctrl+P / Ctrl+N (readline-standard; arrows are the
539
+ // terminal's wheel in this mode)
540
+ if (data === '\x10') {
541
+ if (this.histIdx < this.history.length - 1) {
542
+ this.histIdx++;
543
+ this.input = this.history[this.histIdx] ?? '';
544
+ this.caret = this.input.length;
545
+ this.cmdIdx = 0;
546
+ this.dirty = true;
547
+ }
548
+ return;
549
+ }
550
+ if (data === '\x0e') {
551
+ if (this.histIdx > 0) {
552
+ this.histIdx--;
553
+ this.input = this.history[this.histIdx] ?? '';
554
+ }
555
+ else {
556
+ this.histIdx = -1;
557
+ this.input = '';
558
+ }
559
+ this.caret = this.input.length;
560
+ this.cmdIdx = 0;
561
+ this.dirty = true;
562
+ return;
563
+ }
564
+ if (data === '\x1b[C') {
565
+ if (this.caret < this.input.length) {
566
+ this.caret++;
567
+ this.dirty = true;
568
+ }
569
+ return;
570
+ }
571
+ if (data === '\x1b[D') {
572
+ if (this.caret > 0) {
573
+ this.caret--;
574
+ this.dirty = true;
575
+ }
576
+ return;
577
+ }
578
+ if (data === '\x1b[5~') {
579
+ this.scrollFromBottom += 10;
580
+ this.dirty = true;
581
+ return;
582
+ }
583
+ if (data === '\x1b[6~') {
584
+ this.scrollFromBottom = Math.max(0, this.scrollFromBottom - 10);
585
+ this.dirty = true;
586
+ return;
587
+ }
588
+ if (data === '\x1b[H') {
589
+ this.scrollFromBottom = 100000;
590
+ this.dirty = true;
591
+ return;
592
+ }
593
+ if (data === '\x1b[F') {
594
+ this.scrollFromBottom = 0;
595
+ this.dirty = true;
596
+ return;
597
+ }
598
+ if (data === '\x0c') {
599
+ this.dirty = true;
600
+ return;
601
+ }
602
+ if (data.startsWith('\x1b') || data < ' ')
603
+ return;
604
+ // printable text (CJK / IME preedit arrives as normal chunks)
605
+ this.input = this.input.slice(0, this.caret) + data + this.input.slice(this.caret);
606
+ this.caret += data.length;
607
+ this.cmdIdx = 0;
608
+ this.dirty = true;
609
+ }
610
+ /* ---------------- rendering ---------------- */
611
+ render() {
612
+ if (!this.running || !this.dirty)
613
+ return;
614
+ this.dirty = false;
615
+ const W = stdout.columns || 100;
616
+ const H = stdout.rows || 30;
617
+ const frame = [];
618
+ // modal mouse reporting follows the palette (see syncMouseReporting);
619
+ // click rows are re-recorded every frame because screen positions move
620
+ this.syncMouseReporting();
621
+ this.paletteClickRows.length = 0;
622
+ const spin = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[this.spinnerFrame] ?? ' ';
623
+ // Header = pure identity strip: logo · model · cwd · skills (+ mode tag
624
+ // when set). NO status word up here at all (T1-v2, user-overturned: the
625
+ // run indicator moved to the input-box status line in 0df4ba7 but the
626
+ // old right slot kept showing a stale "idle" - an orphan status.
627
+ // Moving a thing means deleting it from where it was).
628
+ // The ONLY live run indicator is the status line above the input box.
629
+ const headLeft = ` ${BOLD('⚙ hmh')} ${DIM('·')} ${CYAN(this.model)} ${DIM('·')} ${this.cwdName} ${DIM('·')} ${this.skillCount} ${this.t.tuiSkills}` + (this.modeTag ? ` ${this.modeTag}` : '');
630
+ frame.push(truncateTo(headLeft, W));
631
+ frame.push(DIM('─'.repeat(W)));
632
+ const allLines = [];
633
+ for (const e of this.entries)
634
+ allLines.push(...e.lines);
635
+ const cmdHits = this.panelItems(this.input);
636
+ // +1: the palette's dim key-hint footer row shares the layout budget
637
+ const cmdRows = cmdHits.length ? Math.min(cmdHits.length, 6) + 1 : 0;
638
+ const approvalRows = this.approval ? 3 : 0;
639
+ // the input box grows with wrapped content: rows + 2 border lines + 1
640
+ // status line. Cap it at half the screen so the transcript keeps room;
641
+ // beyond that the inner text clips (rare - one line holds ~100+ cols).
642
+ const inputRows = (() => {
643
+ const iw = Math.max(10, W - 6);
644
+ let n = 1;
645
+ let w = 0;
646
+ for (const ch of this.input) {
647
+ const cwv = cw(ch);
648
+ if (w + cwv > iw) {
649
+ n++;
650
+ w = 0;
651
+ }
652
+ w += cwv;
653
+ }
654
+ return Math.min(n, Math.max(1, Math.floor(H / 2) - 3));
655
+ })();
656
+ const statusRows = this.busy ? 1 : 0;
657
+ const viewH = Math.max(3, H - 4 - inputRows - approvalRows - cmdRows - statusRows);
658
+ const start = Math.max(0, allLines.length - viewH - this.scrollFromBottom);
659
+ const view = allLines.slice(start, start + viewH);
660
+ for (let i = 0; i < viewH; i++)
661
+ frame.push(i < view.length ? truncateTo(view[i], W) : '');
662
+ if (this.approval) {
663
+ const argsTxt = JSON.stringify(this.approval.args);
664
+ frame.push(YELLOW(this.t.tuiApproval) + ' ' + YELLOW(BOLD(this.approval.name)) + ' ' + DIM(truncateTo(argsTxt, Math.max(0, W - 24))));
665
+ frame.push(` [y] ${GREEN(this.t.tuiApprove)} [n] ${RED(this.t.tuiDeny)} ${DIM(this.t.tuiApprovalHint)}`);
666
+ frame.push(DIM('─'.repeat(W)));
667
+ }
668
+ // busy status line right above the input box (same position as the web
669
+ // UI): spinning glyph + running text, hidden when idle. Claude Code logic.
670
+ if (this.busy) {
671
+ frame.push(YELLOW(`${spin} ${this.t.tuiRunning}${this.status && this.status !== this.t.tuiRunning ? ' · ' + DIM(this.status) : ''}`));
672
+ }
673
+ // palette (slash commands or the /model picker): shows while the input
674
+ // starts with '/'; arrows move the selection (scrolling 6-row window),
675
+ // Enter/Tab run/complete the highlighted row; the last palette line is
676
+ // a dim key hint so the picker is discoverable without reading docs
677
+ if (cmdRows) {
678
+ const dataRows = Math.min(cmdHits.length, 6);
679
+ const from = Math.max(0, Math.min(this.cmdIdx - 5, cmdHits.length - dataRows));
680
+ for (let i = 0; i < dataRows; i++) {
681
+ const gi = from + i;
682
+ const c = cmdHits[gi];
683
+ const sel = gi === this.cmdIdx;
684
+ frame.push(truncateTo((sel ? '› ' : ' ') + (sel ? CYAN(c.name) : DIM(c.name)) + ' ' + DIM(truncateTo(c.desc, 46)), W - 1));
685
+ // row lands at screen line frame.length (render addresses rows
686
+ // 1-based); only rows that survive the H clip are clickable
687
+ if (frame.length <= H)
688
+ this.paletteClickRows.push({ row: frame.length, idx: gi });
689
+ }
690
+ frame.push(DIM(truncateTo(' ' + this.t.panelHint, W - 1)));
691
+ }
692
+ // input box: width-aware auto-wrap. The old render sliced the input by
693
+ // character count (CJK chars are 2 columns -> the line overflowed the
694
+ // frame and got hard-wrapped by the terminal, tearing the layout) and
695
+ // only ever showed the tail of long input. Now the content wraps inside
696
+ // a growing box, '❯' marks the first row, and the caret tracks the
697
+ // visible position across wraps.
698
+ const iw = Math.max(10, W - 6); // inner text width
699
+ const inputCap = Math.max(1, Math.floor(H / 2) - 3); // keep transcript room
700
+ const sliceToCols = (text, maxCols) => {
701
+ let w = 0;
702
+ let n = 0;
703
+ for (const ch of text) {
704
+ const chw = cw(ch);
705
+ if (w + chw > maxCols)
706
+ break;
707
+ w += chw;
708
+ n += ch.length;
709
+ }
710
+ return [text.slice(0, n), w];
711
+ };
712
+ // caret placement in display columns (code-point aware)
713
+ let caretCols = 0;
714
+ {
715
+ let idx = 0;
716
+ for (const ch of this.input) {
717
+ if (idx >= this.caret)
718
+ break;
719
+ caretCols += cw(ch);
720
+ idx += ch.length;
721
+ }
722
+ }
723
+ // wrap the input into rows of iw columns, remembering each row's first
724
+ // code-point index and the row/col of the caret; beyond the cap, show
725
+ // the tail rows around the caret (the head clips away)
726
+ const rows = [];
727
+ {
728
+ let rowStart = 0;
729
+ let w = 0;
730
+ let idx = 0;
731
+ let cur = '';
732
+ for (const ch of this.input) {
733
+ const chw = cw(ch);
734
+ if (w + chw > iw) {
735
+ rows.push({ text: cur, start: rowStart });
736
+ rowStart = idx;
737
+ w = 0;
738
+ cur = '';
739
+ }
740
+ cur += ch;
741
+ w += chw;
742
+ idx += ch.length;
743
+ }
744
+ rows.push({ text: cur, start: rowStart });
745
+ }
746
+ if (rows.length > inputCap) {
747
+ let caretRow0 = 0;
748
+ for (let i = 0; i < rows.length; i++) {
749
+ const end = rows[i].start + rows[i].text.length;
750
+ if (this.caret >= rows[i].start && this.caret <= end) {
751
+ caretRow0 = i;
752
+ break;
753
+ }
754
+ }
755
+ const from = Math.max(0, Math.min(caretRow0 - (inputCap - 1), rows.length - inputCap));
756
+ const cut = rows[from].start;
757
+ const clippedRows = rows.slice(from, from + inputCap).map((r) => ({ text: r.text, start: r.start - cut }));
758
+ clippedRows[0].text = (from > 0 ? '…' : '') + clippedRows[0].text;
759
+ clippedRows[0].start += from > 0 ? 1 : 0;
760
+ rows.length = 0;
761
+ rows.push(...clippedRows);
762
+ }
763
+ let caretRow = 0;
764
+ let caretCol = 0;
765
+ for (let i = 0; i < rows.length; i++) {
766
+ const end = rows[i].start + rows[i].text.length;
767
+ if (this.caret >= rows[i].start && this.caret <= end) {
768
+ caretRow = i;
769
+ break;
770
+ }
771
+ }
772
+ caretCol = Math.max(0, strWidth(rows[caretRow].text.slice(0, this.caret - rows[caretRow].start)));
773
+ const boxRows = Math.max(1, rows.length);
774
+ frame.push(DIM('┌' + '─'.repeat(iw + 2) + '┐'));
775
+ for (let i = 0; i < boxRows; i++) {
776
+ const rowText = rows[i].text;
777
+ if (i === caretRow) {
778
+ const inRow = this.caret - rows[i].start;
779
+ const before = rowText.slice(0, inRow);
780
+ const atChar = rowText.slice(inRow, inRow + 1) || ' ';
781
+ const after = rowText.slice(inRow + atChar.length);
782
+ const atW = cw(atChar === ' ' ? ' ' : atChar);
783
+ const pad = ' '.repeat(Math.max(0, iw - strWidth(before) - atW - strWidth(after)));
784
+ const caretSpan = this.busy ? DIM(atChar === ' ' ? ' ' : atChar) : `\x1b[7m${atChar === ' ' ? ' ' : atChar}\x1b[27m`;
785
+ frame.push(DIM('│ ') + (i === 0 ? '❯ ' : ' ') + before + caretSpan + after + pad + DIM(' │'));
786
+ }
787
+ else {
788
+ const [clipped] = sliceToCols(rowText, iw);
789
+ const pad = ' '.repeat(Math.max(0, iw - strWidth(clipped)));
790
+ frame.push(DIM('│ ') + (i === 0 ? '❯ ' : ' ') + clipped + pad + DIM(' │'));
791
+ }
792
+ }
793
+ frame.push(DIM('└' + '─'.repeat(iw + 2) + '┘'));
794
+ const hints = this.scrollFromBottom > 0
795
+ ? `${DIM(this.t.tuiScrolled)}`
796
+ : `${DIM(this.t.tuiHints)}`;
797
+ const stat = this.status && !this.busy ? DIM(this.status) : '';
798
+ frame.push(truncateTo(hints + ' '.repeat(Math.max(1, W - strWidth(stripAnsi(hints)) - strWidth(stat))) + stat, W - 1));
799
+ // Absolute per-row addressing: CUP resets the column and cancels the
800
+ // pending wrap a full-width line leaves behind — "\x1b[B" joins would skip
801
+ // a row on immediate-wrap terminals (conhost) and push the frame past the
802
+ // last line, scrolling the header away and clipping the input box.
803
+ const visible = frame.slice(0, H);
804
+ let out = '';
805
+ for (let i = 0; i < visible.length; i++)
806
+ out += `\x1b[${i + 1};1H\x1b[2K${visible[i]}`;
807
+ stdout.write(out + '\x1b[0J');
808
+ }
809
+ }
810
+ /* ---------------- driver ---------------- */
811
+ export async function tui(yes, noWeb = false) {
812
+ let cfg = await loadConfig();
813
+ let autoApprove = yes || cfg.approval === 'auto';
814
+ if (!stdin.isTTY) {
815
+ stdout.write(strings((cfg.locale ?? 'zh')).tuiNeedsTty + '\n');
816
+ process.exitCode = 1;
817
+ return;
818
+ }
819
+ const home = homeDir();
820
+ let t = strings((cfg.locale ?? 'zh'));
821
+ const { reg, clients } = await buildRegistry({ announce: false });
822
+ // auto-link: bring the web UI up in the background (hmh tui --no-web skips)
823
+ const webUp = noWeb ? false : await ensureWebDaemon(DEFAULT_WEB_PORT);
824
+ const rt = new TuiRuntime();
825
+ const skills = await listSkills(home);
826
+ const chatModel = resolveProvider(cfg, 'chat').model;
827
+ rt.configure(chatModel, basename(process.cwd()), skills.length, (cfg.locale ?? 'zh'));
828
+ rt.setModelChoices(listProviders(cfg).map((v) => ({ name: v.name, desc: `${v.model}${v.purposes.length ? ' (' + v.purposes.join('/') + ')' : ''}` })));
829
+ if (autoApprove)
830
+ rt.setModeTag('🔥');
831
+ rt.addText(t.tuiWelcome(chatModel), 'dim');
832
+ if (webUp)
833
+ rt.addText(t.tuiWebLinked(DEFAULT_WEB_PORT), 'dim');
834
+ let history = [];
835
+ rt.onSubmit(() => {
836
+ const line = rt.consumeInput().trim();
837
+ if (!line)
838
+ return;
839
+ void handleLine(line);
840
+ });
841
+ async function handleLine(line) {
842
+ if (line === '/exit' || line === '/quit') {
843
+ rt.destroy();
844
+ for (const c of clients)
845
+ c.close();
846
+ process.exit(0);
847
+ }
848
+ if (line === '?' || line === '/help') {
849
+ rt.addText(COMMANDS.map((c) => ' ' + c.name.padEnd(11) + ' ' + String(t[c.key])).join('\n'), 'dim');
850
+ return;
851
+ }
852
+ if (line === '/clear') {
853
+ rt.clearScreen();
854
+ return;
855
+ }
856
+ if (line === '/status') {
857
+ rt.setStatus(t.tuiStatus(cfg.locale ?? 'zh', skills.length, chatModel));
858
+ return;
859
+ }
860
+ if (line === '/tools') {
861
+ for (const tool of reg.list())
862
+ rt.addText(`${tool.name}${tool.needsApproval ? YELLOW(' [gated]') : ''} — ${tool.description.split('\n')[0].slice(0, 80)}`);
863
+ return;
864
+ }
865
+ if (line === '/skills') {
866
+ const active = await listSkills(home);
867
+ const drafts = await listDrafts(home);
868
+ for (const s of active)
869
+ rt.addText(`${GREEN('+')} ${s.name} — ${s.description}`);
870
+ for (const s of drafts)
871
+ rt.addText(`${YELLOW('~')} ${s.name} — ${s.description}`);
872
+ return;
873
+ }
874
+ if (line === '/mcp') {
875
+ for (const [name, c] of Object.entries(cfg.mcpServers ?? {}))
876
+ rt.addText(`${name} — ${c.type}${c.trusted ? ' · trusted' : ' · gated'}`);
877
+ return;
878
+ }
879
+ if (line === '/web') {
880
+ rt.setBusy(true, '/web');
881
+ const up = await ensureWebDaemon(DEFAULT_WEB_PORT);
882
+ rt.setBusy(false);
883
+ rt.addText(up ? t.tuiWebLinked(DEFAULT_WEB_PORT) : t.tuiWebHint, 'dim');
884
+ return;
885
+ }
886
+ if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
887
+ const turnOn = line === '/yolo' ? !autoApprove : line === '/yolo on';
888
+ autoApprove = turnOn;
889
+ rt.setModeTag(turnOn ? '🔥' : '');
890
+ rt.addText(turnOn ? t.yoloOn : t.yoloOff, turnOn ? 'plain' : 'dim');
891
+ return;
892
+ }
893
+ if (line === '/lang' || line.startsWith('/lang ')) {
894
+ const target = nextLocale(cfg.locale ?? 'zh', line.slice(5));
895
+ cfg = await setLocale(target);
896
+ t = strings(target);
897
+ rt.configure(chatModel, basename(process.cwd()), skills.length, target);
898
+ rt.addText(GREEN('✓') + ' ' + t.langSwitched(target));
899
+ return;
900
+ }
901
+ if (line === '/model' || line.startsWith('/model ')) {
902
+ const arg = line.slice(7).trim();
903
+ if (!arg) {
904
+ // bare /model: the LIVE picker is the single menu. Printing a
905
+ // static provider list here too showed TWO model menus at once
906
+ // (one selectable, one not) - transcript stays minimal
907
+ rt.openModelPicker();
908
+ return;
909
+ }
910
+ rt.setBusy(true, '/model');
911
+ try {
912
+ cfg = await setChatRoute(arg);
913
+ rt.setModelChoices(listProviders(cfg).map((v) => ({ name: v.name, desc: `${v.model}${v.purposes.length ? ' (' + v.purposes.join('/') + ')' : ''}` })));
914
+ rt.addText(GREEN('✓') + ` chat → ${arg} · ${resolveProvider(cfg, 'chat').model}`);
915
+ }
916
+ catch (err) {
917
+ const preset = PROVIDER_PRESETS.find((p) => p.name === arg);
918
+ rt.addText(preset ? t.cmdModelPreset(arg, preset.envVar || '(local)') : String(err), 'err');
919
+ }
920
+ finally {
921
+ rt.setBusy(false);
922
+ }
923
+ return;
924
+ }
925
+ if (line === '/providers' || line === '/providers scan') {
926
+ rt.setBusy(true, '/providers scan');
927
+ try {
928
+ const { readFile } = await import('node:fs/promises');
929
+ const found = await detectLocalProviders(cfg, readFile);
930
+ if (line === '/providers') {
931
+ rt.addText(found.length
932
+ ? found.map((p) => `${YELLOW('+')} ${p.name} — ${p.model} (${p.envVar})`).join('\n') + '\n' + DIM(t.cmdProvidersScanHint)
933
+ : DIM(t.cmdProvidersListed), 'plain');
934
+ }
935
+ else {
936
+ if (!found.length) {
937
+ rt.addText(DIM(t.cmdProvidersNone) + Object.keys(cfg.providers ?? {}).join(', '), 'plain');
938
+ }
939
+ else {
940
+ const r = await addProviders(found.map((p) => ({ name: p.name, baseUrl: p.baseUrl, model: p.model })));
941
+ cfg = r.cfg;
942
+ rt.setModelChoices(listProviders(cfg).map((v) => ({ name: v.name, desc: `${v.model}${v.purposes.length ? ' (' + v.purposes.join('/') + ')' : ''}` })));
943
+ rt.addText(GREEN('✓') + ' ' + t.cmdProvidersAdded(r.added.length, r.added.join(', ')));
944
+ }
945
+ }
946
+ }
947
+ catch (err) {
948
+ rt.addText(String(err), 'err');
949
+ }
950
+ finally {
951
+ rt.setBusy(false);
952
+ }
953
+ return;
954
+ }
955
+ if (line === '/ops') {
956
+ rt.setBusy(true, '/ops');
957
+ try {
958
+ const { harmonyOpsStatus } = await import('@hmharness/domain-ops');
959
+ const r = await harmonyOpsStatus.execute({}, { cwd: process.cwd(), home });
960
+ rt.addText(r.output);
961
+ }
962
+ catch (err) {
963
+ rt.addText(String(err), 'err');
964
+ }
965
+ finally {
966
+ rt.setBusy(false);
967
+ }
968
+ return;
969
+ }
970
+ if (line === '/ops scan') {
971
+ rt.setBusy(true, t.tuiRadarScanning);
972
+ try {
973
+ const { harmonyOpsRadarScan } = await import('@hmharness/domain-ops');
974
+ const r = await harmonyOpsRadarScan.execute({}, { cwd: process.cwd(), home });
975
+ rt.addText(r.output);
976
+ }
977
+ catch (err) {
978
+ rt.addText(String(err), 'err');
979
+ }
980
+ finally {
981
+ rt.setBusy(false);
982
+ }
983
+ return;
984
+ }
985
+ if (line === '/bench') {
986
+ rt.setBusy(true, '/bench');
987
+ try {
988
+ const { chat } = await import('@hmharness/kernel');
989
+ const { results, passRate } = await runBench(home, async (c) => {
990
+ // plain model call keeps the TUI bench fast; loop cases fall back
991
+ // to the dedicated `hmh bench` command
992
+ if (c.tools)
993
+ return "(skipped in tui; run 'hmh bench')";
994
+ const r = await chat(cfg.provider, [{ role: 'user', content: c.prompt }]);
995
+ return r.message.content ?? '';
996
+ });
997
+ for (const r of results)
998
+ rt.addText(`${r.pass ? GREEN('PASS') : YELLOW('FAIL')} ${r.name} — ${r.detail}`);
999
+ rt.addText(t.tuiPassRate(`${(passRate * 100).toFixed(0)}%`));
1000
+ }
1001
+ catch (err) {
1002
+ rt.addText(String(err), 'err');
1003
+ }
1004
+ finally {
1005
+ rt.setBusy(false);
1006
+ }
1007
+ return;
1008
+ }
1009
+ if (line === '/evolve') {
1010
+ rt.setBusy(true, '/evolve');
1011
+ try {
1012
+ const { chat } = await import('@hmharness/kernel');
1013
+ const report = await runEvolution({
1014
+ home,
1015
+ provider: cfg.provider,
1016
+ runCase: async (c) => {
1017
+ if (c.tools)
1018
+ return '(skipped in tui; run hmh evolve)';
1019
+ const r = await chat(cfg.provider, [{ role: 'user', content: c.prompt }]);
1020
+ return r.message.content ?? '';
1021
+ },
1022
+ log: (l) => rt.addText(l, 'dim'),
1023
+ });
1024
+ rt.addText(t.tuiEvolveDone(report.proposals.length, report.insightCount, report.noteCount));
1025
+ }
1026
+ catch (err) {
1027
+ rt.addText(String(err), 'err');
1028
+ }
1029
+ finally {
1030
+ rt.setBusy(false);
1031
+ }
1032
+ return;
1033
+ }
1034
+ rt.addText(`❯ ${line}`);
1035
+ rt.setBusy(true, t.running);
1036
+ let appender = null;
1037
+ let kind = null;
1038
+ try {
1039
+ const result = await runAgentTask({
1040
+ task: line,
1041
+ registry: reg,
1042
+ cfg,
1043
+ yes: autoApprove,
1044
+ resumeMessages: history,
1045
+ approvalAsk: (name, args) => rt.requestApproval(name, args),
1046
+ events: {
1047
+ onLine: (l) => { if (kind === 'reasoning')
1048
+ rt.foldThinking(); appender = null; kind = null; rt.addText(l, 'dim'); },
1049
+ onDelta: (k, chunk) => {
1050
+ if (k !== kind) {
1051
+ if (kind === 'reasoning')
1052
+ rt.foldThinking(); // collapse before the next phase
1053
+ appender = rt.startStream(k === 'reasoning' ? 'think' : 'say');
1054
+ kind = k;
1055
+ }
1056
+ appender?.(chunk);
1057
+ },
1058
+ onToolCall: (name, args) => {
1059
+ if (kind === 'reasoning')
1060
+ rt.foldThinking();
1061
+ appender = null;
1062
+ kind = null;
1063
+ rt.addText(`${YELLOW('●')} ${CYAN(name)} ${DIM(JSON.stringify(args).slice(0, 100))}`);
1064
+ },
1065
+ onToolResult: (name, output, isError) => {
1066
+ const dot = isError ? RED('✗') : GREEN('•');
1067
+ rt.addText(` ${dot} ${DIM('⎿ ' + output.split('\n').slice(0, 2).join(' ').slice(0, 110))}`);
1068
+ },
1069
+ },
1070
+ });
1071
+ rt.setBusy(false);
1072
+ rt.setStatus(`↑${result.usage.promptTokens} ↓${result.usage.completionTokens} tok · ${result.turns} turns · ${result.toolUses} tools`);
1073
+ history = [...history, { role: 'user', content: line }, ...result.messages.slice(history.length + 2)];
1074
+ }
1075
+ catch (err) {
1076
+ rt.setBusy(false);
1077
+ rt.addText(String(err), 'err');
1078
+ }
1079
+ }
1080
+ await rt.waitExit();
1081
+ rt.destroy();
1082
+ for (const c of clients)
1083
+ c.close();
1084
+ stdout.write('\n');
1085
+ }