@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 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, otherwise
93
- * slash commands. Rows are {name, desc} so both share one renderer. */
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
- this.input = this.input.startsWith('/model') ? `/model ${pick} ` : pick + ' ';
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, otherwise
275
- * slash commands. Rows are {name, desc} so both share one renderer. */
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
- : hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
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
- // list the 8 newest sessions: id prefix (enough to disambiguate) + first user line
930
- const { readdir } = await import('node:fs/promises');
931
- let files = [];
932
- try {
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
- for (const f of recent) {
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.2",
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.2",
47
- "@hmharness/evolution": "0.4.2",
48
- "@hmharness/domain-harmony": "0.4.2",
49
- "@hmharness/domain-ops": "0.4.2",
50
- "@hmharness/agent": "0.4.2",
51
- "@hmharness/web": "0.4.2"
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
  }