@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/main.d.ts +2 -0
- package/dist/main.js +641 -0
- package/dist/prompt.d.ts +7 -0
- package/dist/prompt.js +14 -0
- package/dist/runner.d.ts +68 -0
- package/dist/runner.js +179 -0
- package/dist/spawn.d.ts +29 -0
- package/dist/spawn.js +64 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +137 -0
- package/dist/tui.d.ts +110 -0
- package/dist/tui.js +1085 -0
- package/dist/web-daemon.d.ts +15 -0
- package/dist/web-daemon.js +106 -0
- package/package.json +53 -0
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
|
+
}
|