@hmharness/cli 0.4.2 → 0.4.4
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.d.ts +17 -2
- package/dist/tui.js +88 -23
- package/package.json +7 -7
package/dist/tui.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ export declare class TuiRuntime {
|
|
|
46
46
|
private modeTag;
|
|
47
47
|
/** rows for the `/model ` picker (configured providers first, set by driver) */
|
|
48
48
|
private modelChoices;
|
|
49
|
+
/** rows for the `/resume ` session picker (id prefix + first user line) */
|
|
50
|
+
private sessionChoices;
|
|
49
51
|
/** Wheel/click handling: NO mouse reporting by default - select/copy
|
|
50
52
|
* always works and terminals translate the wheel to arrow keys on the
|
|
51
53
|
* alternate screen. Reporting turns on ONLY while a palette is open
|
|
@@ -63,9 +65,16 @@ export declare class TuiRuntime {
|
|
|
63
65
|
name: string;
|
|
64
66
|
desc: string;
|
|
65
67
|
}>): void;
|
|
68
|
+
setSessionChoices(list: Array<{
|
|
69
|
+
name: string;
|
|
70
|
+
desc: string;
|
|
71
|
+
}>): void;
|
|
66
72
|
/** Focus the /model picker (used by the bare `/model` command so the
|
|
67
73
|
* printed list is never a dead end - the live palette opens on it). */
|
|
68
74
|
openModelPicker(): void;
|
|
75
|
+
/** Focus the /resume session picker - same live palette as /model:
|
|
76
|
+
* arrows/wheel navigate, Enter loads the highlighted session. */
|
|
77
|
+
openSessionPicker(): void;
|
|
69
78
|
/** Run the highlighted palette row (shared by Enter and palette clicks);
|
|
70
79
|
* with no palette open it just submits the typed input. */
|
|
71
80
|
private pickHighlighted;
|
|
@@ -89,8 +98,9 @@ export declare class TuiRuntime {
|
|
|
89
98
|
* while it shows, clicks choose its rows and the wheel drives its
|
|
90
99
|
* selection; drag-select resumes the moment it closes. */
|
|
91
100
|
private syncMouseReporting;
|
|
92
|
-
/** The palette data source: `/model ` opens the model picker,
|
|
93
|
-
* slash commands. Rows are {name, desc} so
|
|
101
|
+
/** The palette data source: `/model ` opens the model picker, `/resume `
|
|
102
|
+
* the session picker, otherwise slash commands. Rows are {name, desc} so
|
|
103
|
+
* all three share one renderer, keyboard and click machinery. */
|
|
94
104
|
private panelItems;
|
|
95
105
|
configure(model: string, cwdName: string, skillCount: number, locale: Locale, version?: string): void;
|
|
96
106
|
destroy(): void;
|
|
@@ -114,4 +124,9 @@ export declare class TuiRuntime {
|
|
|
114
124
|
private onKey;
|
|
115
125
|
render(): void;
|
|
116
126
|
}
|
|
127
|
+
/** First user line of a session file WITHOUT a full parse: peek the first
|
|
128
|
+
* 64KB, scan lines for the first user event. With hundreds of sessions a
|
|
129
|
+
* full loadTranscript per row would stall the picker for seconds; the user
|
|
130
|
+
* event is always near the head, so the peek is O(64KB) per session. */
|
|
131
|
+
export declare function firstUserLinePeek(file: string): Promise<string>;
|
|
117
132
|
export declare function tui(yes: boolean, noWeb?: boolean): Promise<void>;
|
package/dist/tui.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { stdin, stdout } from 'node:process';
|
|
14
14
|
import { basename, join } from 'node:path';
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
|
+
import { open as fopen } from 'node:fs/promises';
|
|
16
17
|
import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders } from '@hmharness/kernel';
|
|
17
18
|
import { listDrafts, listSkills, runBench, runEvolution } from '@hmharness/evolution';
|
|
18
19
|
import { buildRegistry, runAgentTask, strings } from '@hmharness/agent';
|
|
@@ -167,6 +168,8 @@ export class TuiRuntime {
|
|
|
167
168
|
modeTag = '';
|
|
168
169
|
/** rows for the `/model ` picker (configured providers first, set by driver) */
|
|
169
170
|
modelChoices = [];
|
|
171
|
+
/** rows for the `/resume ` session picker (id prefix + first user line) */
|
|
172
|
+
sessionChoices = [];
|
|
170
173
|
/** Wheel/click handling: NO mouse reporting by default - select/copy
|
|
171
174
|
* always works and terminals translate the wheel to arrow keys on the
|
|
172
175
|
* alternate screen. Reporting turns on ONLY while a palette is open
|
|
@@ -195,6 +198,10 @@ export class TuiRuntime {
|
|
|
195
198
|
this.modelChoices = list;
|
|
196
199
|
this.dirty = true;
|
|
197
200
|
}
|
|
201
|
+
setSessionChoices(list) {
|
|
202
|
+
this.sessionChoices = list;
|
|
203
|
+
this.dirty = true;
|
|
204
|
+
}
|
|
198
205
|
/** Focus the /model picker (used by the bare `/model` command so the
|
|
199
206
|
* printed list is never a dead end - the live palette opens on it). */
|
|
200
207
|
openModelPicker() {
|
|
@@ -203,13 +210,26 @@ export class TuiRuntime {
|
|
|
203
210
|
this.cmdIdx = 0;
|
|
204
211
|
this.dirty = true;
|
|
205
212
|
}
|
|
213
|
+
/** Focus the /resume session picker - same live palette as /model:
|
|
214
|
+
* arrows/wheel navigate, Enter loads the highlighted session. */
|
|
215
|
+
openSessionPicker() {
|
|
216
|
+
this.input = '/resume ';
|
|
217
|
+
this.caret = this.input.length;
|
|
218
|
+
this.cmdIdx = 0;
|
|
219
|
+
this.dirty = true;
|
|
220
|
+
}
|
|
206
221
|
/** Run the highlighted palette row (shared by Enter and palette clicks);
|
|
207
222
|
* with no palette open it just submits the typed input. */
|
|
208
223
|
pickHighlighted() {
|
|
209
224
|
const hits = this.panelItems(this.input);
|
|
210
225
|
const pick = hits.length ? hits[Math.min(this.cmdIdx, hits.length - 1)].name : '';
|
|
211
226
|
if (pick) {
|
|
212
|
-
|
|
227
|
+
if (this.input.startsWith('/model'))
|
|
228
|
+
this.input = `/model ${pick} `;
|
|
229
|
+
else if (this.input.startsWith('/resume'))
|
|
230
|
+
this.input = `/resume ${pick}`;
|
|
231
|
+
else
|
|
232
|
+
this.input = pick + ' ';
|
|
213
233
|
this.caret = this.input.length;
|
|
214
234
|
this.cmdIdx = 0;
|
|
215
235
|
}
|
|
@@ -271,8 +291,9 @@ export class TuiRuntime {
|
|
|
271
291
|
this.mouseReported = want;
|
|
272
292
|
stdout.write(want ? '\x1b[?1000h\x1b[?1006h' : '\x1b[?1000l\x1b[?1006l');
|
|
273
293
|
}
|
|
274
|
-
/** The palette data source: `/model ` opens the model picker,
|
|
275
|
-
* slash commands. Rows are {name, desc} so
|
|
294
|
+
/** The palette data source: `/model ` opens the model picker, `/resume `
|
|
295
|
+
* the session picker, otherwise slash commands. Rows are {name, desc} so
|
|
296
|
+
* all three share one renderer, keyboard and click machinery. */
|
|
276
297
|
panelItems(input) {
|
|
277
298
|
if (input === '/model' || input.startsWith('/model ')) {
|
|
278
299
|
const q = input.slice(6).trim().toLowerCase();
|
|
@@ -283,6 +304,10 @@ export class TuiRuntime {
|
|
|
283
304
|
const all = [...configured, ...rest];
|
|
284
305
|
return q ? all.filter((i) => i.name.toLowerCase().startsWith(q)) : all;
|
|
285
306
|
}
|
|
307
|
+
if (input === '/resume' || input.startsWith('/resume ')) {
|
|
308
|
+
const q = input.slice(7).trim().toLowerCase();
|
|
309
|
+
return q ? this.sessionChoices.filter((i) => i.name.toLowerCase().startsWith(q)) : this.sessionChoices;
|
|
310
|
+
}
|
|
286
311
|
return matchCommands(input).map((c) => ({ name: c.name, desc: String(this.t[c.key]) }));
|
|
287
312
|
}
|
|
288
313
|
configure(model, cwdName, skillCount, locale, version) {
|
|
@@ -509,6 +534,12 @@ export class TuiRuntime {
|
|
|
509
534
|
this.openModelPicker();
|
|
510
535
|
return;
|
|
511
536
|
}
|
|
537
|
+
// same two-stage rule for /resume: bare command + Enter opens the
|
|
538
|
+
// session picker (focus moves to the list), never loads row 0 blindly
|
|
539
|
+
if (this.input === '/resume') {
|
|
540
|
+
this.openSessionPicker();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
512
543
|
// palette open: Enter runs the highlighted row (a command, or a
|
|
513
544
|
// /model target), not the raw input; without a palette it submits
|
|
514
545
|
this.pickHighlighted();
|
|
@@ -519,7 +550,9 @@ export class TuiRuntime {
|
|
|
519
550
|
if (hits.length) {
|
|
520
551
|
this.input = this.input.startsWith('/model')
|
|
521
552
|
? `/model ${hits[Math.min(this.cmdIdx, hits.length - 1)].name} `
|
|
522
|
-
:
|
|
553
|
+
: this.input.startsWith('/resume')
|
|
554
|
+
? `/resume ${hits[Math.min(this.cmdIdx, hits.length - 1)].name}`
|
|
555
|
+
: hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
|
|
523
556
|
this.caret = this.input.length;
|
|
524
557
|
this.cmdIdx = 0;
|
|
525
558
|
this.dirty = true;
|
|
@@ -839,6 +872,34 @@ export class TuiRuntime {
|
|
|
839
872
|
}
|
|
840
873
|
}
|
|
841
874
|
/* ---------------- driver ---------------- */
|
|
875
|
+
/** First user line of a session file WITHOUT a full parse: peek the first
|
|
876
|
+
* 64KB, scan lines for the first user event. With hundreds of sessions a
|
|
877
|
+
* full loadTranscript per row would stall the picker for seconds; the user
|
|
878
|
+
* event is always near the head, so the peek is O(64KB) per session. */
|
|
879
|
+
export async function firstUserLinePeek(file) {
|
|
880
|
+
try {
|
|
881
|
+
const fh = await fopen(file, 'r');
|
|
882
|
+
try {
|
|
883
|
+
const buf = Buffer.alloc(65_536);
|
|
884
|
+
const { bytesRead } = await fh.read(buf, 0, 65_536, 0);
|
|
885
|
+
for (const line of buf.toString('utf8', 0, bytesRead).split('\n')) {
|
|
886
|
+
if (!line.includes('"user"'))
|
|
887
|
+
continue;
|
|
888
|
+
try {
|
|
889
|
+
const ev = JSON.parse(line);
|
|
890
|
+
if (ev.t === 'user' && typeof ev.text === 'string')
|
|
891
|
+
return ev.text;
|
|
892
|
+
}
|
|
893
|
+
catch { /* partial line at the buffer edge - no preview */ }
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
await fh.close();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
catch { /* unreadable file - no preview */ }
|
|
901
|
+
return '';
|
|
902
|
+
}
|
|
842
903
|
export async function tui(yes, noWeb = false) {
|
|
843
904
|
let cfg = await loadConfig();
|
|
844
905
|
let autoApprove = yes || cfg.approval === 'auto';
|
|
@@ -925,29 +986,33 @@ export async function tui(yes, noWeb = false) {
|
|
|
925
986
|
if (line === '/resume' || line.startsWith('/resume ')) {
|
|
926
987
|
const arg = line.slice(8).trim();
|
|
927
988
|
const { latestSession, loadTranscript } = await import('@hmharness/kernel');
|
|
989
|
+
const { readdir } = await import('node:fs/promises');
|
|
990
|
+
// ALL sessions, newest first - no arbitrary "recent 8" cap (user
|
|
991
|
+
// challenge: "不应该是所有历史会话吗"). Previews use the 64KB peek,
|
|
992
|
+
// never a full parse, so hundreds of rows still open instantly; the
|
|
993
|
+
// palette scrolls and head-prefix filtering narrows fast.
|
|
994
|
+
let files = [];
|
|
995
|
+
try {
|
|
996
|
+
files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl'));
|
|
997
|
+
}
|
|
998
|
+
catch { /* none */ }
|
|
999
|
+
files.sort();
|
|
1000
|
+
const recent = files.reverse();
|
|
1001
|
+
const rows = [];
|
|
1002
|
+
for (const f of recent) {
|
|
1003
|
+
const firstUser = await firstUserLinePeek(join(home, 'sessions', f));
|
|
1004
|
+
rows.push({ name: f.slice(0, 18), desc: (firstUser || '(无预览)').replace(/\n/g, ' ').slice(0, 56) });
|
|
1005
|
+
}
|
|
1006
|
+
rt.setSessionChoices(rows);
|
|
928
1007
|
if (!arg) {
|
|
929
|
-
//
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl'));
|
|
934
|
-
}
|
|
935
|
-
catch { /* none */ }
|
|
936
|
-
files.sort();
|
|
937
|
-
const recent = files.slice(-8).reverse();
|
|
938
|
-
if (recent.length === 0) {
|
|
1008
|
+
// bare /resume opens the LIVE picker - same arrows/wheel/Enter/click
|
|
1009
|
+
// machinery as /model; a printed text list is a dead end (user-
|
|
1010
|
+
// reported: "上下键无法选择")
|
|
1011
|
+
if (rows.length === 0) {
|
|
939
1012
|
rt.addText(t.cmdResumeNone, 'dim');
|
|
940
1013
|
return;
|
|
941
1014
|
}
|
|
942
|
-
|
|
943
|
-
try {
|
|
944
|
-
const tr = await loadTranscript(join(home, 'sessions', f));
|
|
945
|
-
const firstUser = tr?.messages.find((m) => m.role === 'user')?.content ?? '';
|
|
946
|
-
rt.addText(`${CYAN(f.slice(0, 18))} ${(firstUser || '(no user line)').replace(/\n/g, ' ').slice(0, 60)}`, 'dim');
|
|
947
|
-
}
|
|
948
|
-
catch { /* skip unreadable */ }
|
|
949
|
-
}
|
|
950
|
-
rt.addText(t.cmdResumeHint, 'dim');
|
|
1015
|
+
rt.openSessionPicker();
|
|
951
1016
|
return;
|
|
952
1017
|
}
|
|
953
1018
|
const file = await latestSession(home, arg);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "hmharness command line: one-shot tasks, an interactive REPL, a fullscreen TUI, the web frontend, and direct tool invocation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
"build": "tsc -p tsconfig.build.json"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@hmharness/kernel": "0.4.
|
|
47
|
-
"@hmharness/evolution": "0.4.
|
|
48
|
-
"@hmharness/domain-harmony": "0.4.
|
|
49
|
-
"@hmharness/domain-ops": "0.4.
|
|
50
|
-
"@hmharness/agent": "0.4.
|
|
51
|
-
"@hmharness/web": "0.4.
|
|
46
|
+
"@hmharness/kernel": "0.4.4",
|
|
47
|
+
"@hmharness/evolution": "0.4.4",
|
|
48
|
+
"@hmharness/domain-harmony": "0.4.4",
|
|
49
|
+
"@hmharness/domain-ops": "0.4.4",
|
|
50
|
+
"@hmharness/agent": "0.4.4",
|
|
51
|
+
"@hmharness/web": "0.4.4"
|
|
52
52
|
}
|
|
53
53
|
}
|