@hmharness/cli 0.8.1 → 0.8.2
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.js +32 -7
- package/dist/resume-picker.d.ts +75 -0
- package/dist/resume-picker.js +151 -0
- package/dist/tui.d.ts +26 -18
- package/dist/tui.js +211 -123
- package/package.json +5 -5
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* hmh init create HMH_HOME skeleton + config
|
|
6
6
|
* hmh "do something" one-shot task (full agent loop, streaming)
|
|
7
7
|
* hmh interactive REPL (conversation memory kept)
|
|
8
|
-
* hmh resume [id-prefix]
|
|
8
|
+
* hmh resume [id-prefix|--last] continue a past session (bare: codex-style picker)
|
|
9
9
|
* hmh web [--port=7788] local web frontend (SSE streaming + approvals)
|
|
10
10
|
* hmh tui lite terminal UI (status header + slash commands)
|
|
11
11
|
* hmh ops [scan|brief|stats|status] ops keeper: radar / npm download stats
|
|
@@ -23,7 +23,7 @@ import readline from 'node:readline/promises';
|
|
|
23
23
|
import { stdin, stdout } from 'node:process';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import { stopWebDaemon, startWebDaemon, hmhWebUp, readWebPid } from "./web-daemon.js";
|
|
26
|
-
import { chat, homeDir, resolveProvider, initHome, latestSession, listProviders, loadConfig, loadTranscript, mcpServerTools, Registry, runLoop, setChatRoute, setLocale, } from '@hmharness/kernel';
|
|
26
|
+
import { chat, homeDir, resolveProvider, initHome, latestSession, listProviders, listSessions, loadConfig, loadTranscript, mcpServerTools, Registry, runLoop, setChatRoute, setLocale, } from '@hmharness/kernel';
|
|
27
27
|
import { listSkills, listDrafts, promoteSkill, runBench, runEvolution, rollbackSkill, unpromoteSkill, } from '@hmharness/evolution';
|
|
28
28
|
import { harmonyTools } from '@hmharness/domain-harmony';
|
|
29
29
|
import { baseTools, buildRegistry, buildSystemPrompt, runAgentTask, strings } from '@hmharness/agent';
|
|
@@ -61,6 +61,7 @@ async function runTask(task, taskOpts = {}) {
|
|
|
61
61
|
cfg,
|
|
62
62
|
yes: taskOpts.yes,
|
|
63
63
|
resumeMessages: taskOpts.resumeMessages,
|
|
64
|
+
sessionId: taskOpts.sessionId,
|
|
64
65
|
events: {
|
|
65
66
|
onLine: (l) => stdout.write(DIM(` ${l}\n`)),
|
|
66
67
|
onDelta: (kind, chunk) => {
|
|
@@ -92,7 +93,7 @@ async function runTask(task, taskOpts = {}) {
|
|
|
92
93
|
// working transcript minus the system prompt and the task line we appended
|
|
93
94
|
return { messages: result.messages, sessionId: result.sessionId };
|
|
94
95
|
}
|
|
95
|
-
async function repl(yes, initialHistory) {
|
|
96
|
+
async function repl(yes, initialHistory, initialSessionId) {
|
|
96
97
|
const home = homeDir();
|
|
97
98
|
let cfg = await loadConfig();
|
|
98
99
|
let autoApprove = yes;
|
|
@@ -115,6 +116,9 @@ async function repl(yes, initialHistory) {
|
|
|
115
116
|
// The REPL keeps conversation memory across its own lines (and any
|
|
116
117
|
// resumed history); each line re-injects fresh memory/skills.
|
|
117
118
|
let history = initialHistory ? [...initialHistory] : [];
|
|
119
|
+
// one rollout per conversation: the first task creates it, later lines and
|
|
120
|
+
// `hmh resume` sessions append to it (codex thread semantics)
|
|
121
|
+
let currentSessionId = initialSessionId;
|
|
118
122
|
try {
|
|
119
123
|
while (true) {
|
|
120
124
|
let line;
|
|
@@ -214,6 +218,7 @@ async function repl(yes, initialHistory) {
|
|
|
214
218
|
// line-mode twin of the TUI /clear: clear the conversation so the
|
|
215
219
|
// next task starts fresh (REPL counterpart was missing)
|
|
216
220
|
history = [];
|
|
221
|
+
currentSessionId = undefined;
|
|
217
222
|
stdout.write(DIM(t.cmdClearDone) + '\n');
|
|
218
223
|
continue;
|
|
219
224
|
}
|
|
@@ -254,10 +259,12 @@ async function repl(yes, initialHistory) {
|
|
|
254
259
|
continue;
|
|
255
260
|
}
|
|
256
261
|
try {
|
|
257
|
-
const r = await runTask(line, { yes: autoApprove, sharedRl: rl, registry: reg, clients, resumeMessages: history });
|
|
262
|
+
const r = await runTask(line, { yes: autoApprove, sharedRl: rl, registry: reg, clients, resumeMessages: history, sessionId: currentSessionId });
|
|
258
263
|
// working transcript = [system, ...resumeMessages, user, ...new turns];
|
|
259
264
|
// only the NEW turns (past the replayed prefix) extend history.
|
|
260
265
|
history = [...history, { role: 'user', content: line }, ...r.messages.slice(history.length + 2)];
|
|
266
|
+
// one rollout per REPL conversation (codex thread semantics)
|
|
267
|
+
currentSessionId = r.sessionId;
|
|
261
268
|
}
|
|
262
269
|
catch (err) {
|
|
263
270
|
stdout.write(`error: ${String(err)}\n`);
|
|
@@ -328,7 +335,8 @@ async function main() {
|
|
|
328
335
|
usage:
|
|
329
336
|
hmh "do something" one-shot task (full agent loop, streaming)
|
|
330
337
|
hmh interactive REPL (conversation memory kept, /help for commands)
|
|
331
|
-
hmh resume [id-prefix] continue a past session
|
|
338
|
+
hmh resume [id-prefix|--last] continue a past session (bare = full-screen picker,
|
|
339
|
+
--last = newest session in this directory)
|
|
332
340
|
hmh web start|stop|status web UI as a silent background daemon (no window,
|
|
333
341
|
survives closing everything; log ~/.hmharness/web.log)
|
|
334
342
|
hmh web [--port=7788] web UI in the foreground (debugging)
|
|
@@ -750,7 +758,23 @@ flags:
|
|
|
750
758
|
}
|
|
751
759
|
if (cmd === 'resume') {
|
|
752
760
|
await initHome();
|
|
753
|
-
const
|
|
761
|
+
const home = homeDir();
|
|
762
|
+
// bare `hmh resume` on a TTY = codex `codex resume`: the TUI comes up
|
|
763
|
+
// with the full-frame picker already open (typeahead, cwd filter, sort)
|
|
764
|
+
if (!arg && !rest.includes('--last') && stdin.isTTY) {
|
|
765
|
+
const { tui } = await import("./tui.js");
|
|
766
|
+
await tui(yes, false, { resumeAtStart: true });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
// `--last`: newest rollout in THIS cwd (codex resume --last)
|
|
770
|
+
let file = null;
|
|
771
|
+
if (rest.includes('--last')) {
|
|
772
|
+
const page = await listSessions(home, { cwd: process.cwd(), limit: 1 });
|
|
773
|
+
file = page.items[0]?.file ?? null;
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
file = await latestSession(home, arg);
|
|
777
|
+
}
|
|
754
778
|
if (!file) {
|
|
755
779
|
stdout.write(arg ? `No session matches prefix "${arg}".\n` : 'No sessions yet.\n');
|
|
756
780
|
return;
|
|
@@ -761,7 +785,8 @@ flags:
|
|
|
761
785
|
return;
|
|
762
786
|
}
|
|
763
787
|
stdout.write(DIM(`resuming ${tr.id} · ${tr.messages.length} messages · model ${tr.model}\n`));
|
|
764
|
-
|
|
788
|
+
// repl keeps appending to THIS rollout for the whole conversation
|
|
789
|
+
await repl(yes, tr.messages, tr.id);
|
|
765
790
|
return;
|
|
766
791
|
}
|
|
767
792
|
if (cmd && !cmd.startsWith('-')) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex-style resume picker (transplant of codex-rs/tui/src/resume_picker.rs,
|
|
3
|
+
* trimmed to hmharness's surface). This module is the pure state machine +
|
|
4
|
+
* formatting; TuiRuntime owns rendering and key plumbing. Codex behaviors kept:
|
|
5
|
+
* - typeahead: case-insensitive substring across title/id/cwd/branch
|
|
6
|
+
* (codex Row::matches_query)
|
|
7
|
+
* - toolbar Filter[Cwd|All] x Sort[Updated|Created], Tab cycles focus,
|
|
8
|
+
* arrows change the focused value, changes trigger a reload
|
|
9
|
+
* (codex toggle_sort_key / SessionFilterMode)
|
|
10
|
+
* - Enter accepts, Esc clears the query first then closes, Backspace pops
|
|
11
|
+
* (codex handle_key)
|
|
12
|
+
* - paging: rows arrive page by page; moving within LOAD_NEAR_THRESHOLD of
|
|
13
|
+
* the end asks for the next page (codex maybe_load_more_for_scroll);
|
|
14
|
+
* a query that matches nothing loaded yet keeps searching forward
|
|
15
|
+
* (codex SearchState::Active)
|
|
16
|
+
*/
|
|
17
|
+
import type { SessionSummary } from '@hmharness/kernel';
|
|
18
|
+
export type PickerSort = 'updated' | 'created';
|
|
19
|
+
/** 0 = filter control, 1 = sort control (codex toolbar Tab cycle) */
|
|
20
|
+
export type PickerFocus = 0 | 1;
|
|
21
|
+
export interface PickerState {
|
|
22
|
+
query: string;
|
|
23
|
+
rows: SessionSummary[];
|
|
24
|
+
selected: number;
|
|
25
|
+
sort: PickerSort;
|
|
26
|
+
cwdOnly: boolean;
|
|
27
|
+
focus: PickerFocus;
|
|
28
|
+
/** next listSessions cursor; null = scan complete */
|
|
29
|
+
nextCursor: string | null;
|
|
30
|
+
/** a (re)load is in flight - renders the loading markers */
|
|
31
|
+
loading: boolean;
|
|
32
|
+
/** true before the first page lands (initial vs empty-result states) */
|
|
33
|
+
initial: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function initialPickerState(sort?: PickerSort, cwdOnly?: boolean): PickerState;
|
|
36
|
+
/** codex Row::matches_query: lowercase substring across every display field. */
|
|
37
|
+
export declare function matchesQuery(row: SessionSummary, q: string): boolean;
|
|
38
|
+
export declare function visibleRows(state: PickerState): SessionSummary[];
|
|
39
|
+
export type PickerEffect = {
|
|
40
|
+
kind: 'none';
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'reload';
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'load-more';
|
|
45
|
+
} | {
|
|
46
|
+
kind: 'search-more';
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'accept';
|
|
49
|
+
row: SessionSummary;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'close';
|
|
52
|
+
};
|
|
53
|
+
/** Normalize a raw stdin chunk to the picker's key vocabulary; anything the
|
|
54
|
+
* picker does not own returns null so the caller leaves it to the runtime. */
|
|
55
|
+
export declare function pickerKey(data: string): string | null;
|
|
56
|
+
export declare function reducePicker(state: PickerState, key: string): {
|
|
57
|
+
state: PickerState;
|
|
58
|
+
effect: PickerEffect;
|
|
59
|
+
};
|
|
60
|
+
/** "2m" / "3h" / "4d" relative time for the row tail. */
|
|
61
|
+
export declare function relTime(iso: string, now?: number): string;
|
|
62
|
+
/** One list row: `❯ 2026-09-12T17-30 run a harmless command 2h`.
|
|
63
|
+
* The id prefix (filename timestamp - codex shows the same) is the anchor
|
|
64
|
+
* the eye scans by; the title truncates into the remaining width. */
|
|
65
|
+
export declare function formatRow(row: SessionSummary, selected: boolean, width: number): string;
|
|
66
|
+
/** The search line: `query_` plus the toolbar on the right
|
|
67
|
+
* (codex: `Filter:[Cwd] Sort:[Updated]`, focused control highlighted). */
|
|
68
|
+
export declare function toolbarLine(state: PickerState, labels: {
|
|
69
|
+
filter: string;
|
|
70
|
+
sort: string;
|
|
71
|
+
cwd: string;
|
|
72
|
+
all: string;
|
|
73
|
+
updated: string;
|
|
74
|
+
created: string;
|
|
75
|
+
}, width: number, paint: (s: string, on: boolean) => string): string;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const LOAD_NEAR = 5; // codex LOAD_NEAR_THRESHOLD
|
|
2
|
+
export function initialPickerState(sort = 'updated', cwdOnly = true) {
|
|
3
|
+
return { query: '', rows: [], selected: 0, sort, cwdOnly, focus: 0, nextCursor: null, loading: true, initial: true };
|
|
4
|
+
}
|
|
5
|
+
/** codex Row::matches_query: lowercase substring across every display field. */
|
|
6
|
+
export function matchesQuery(row, q) {
|
|
7
|
+
if (!q)
|
|
8
|
+
return true;
|
|
9
|
+
const needle = q.toLowerCase();
|
|
10
|
+
return [row.title, row.id, row.cwd, row.branch ?? ''].some((f) => f.toLowerCase().includes(needle));
|
|
11
|
+
}
|
|
12
|
+
export function visibleRows(state) {
|
|
13
|
+
return state.query ? state.rows.filter((r) => matchesQuery(r, state.query)) : state.rows;
|
|
14
|
+
}
|
|
15
|
+
/** Normalize a raw stdin chunk to the picker's key vocabulary; anything the
|
|
16
|
+
* picker does not own returns null so the caller leaves it to the runtime. */
|
|
17
|
+
export function pickerKey(data) {
|
|
18
|
+
if (/^\x1bO[A-H]$/.test(data))
|
|
19
|
+
data = '\x1b[' + data[2]; // SS3 arrows, same as TuiRuntime
|
|
20
|
+
if (data === '\x1b[A' || data === '\x1b[B')
|
|
21
|
+
return data === '\x1b[A' ? 'up' : 'down';
|
|
22
|
+
if (data === '\r')
|
|
23
|
+
return 'enter';
|
|
24
|
+
if (data === '\x1b')
|
|
25
|
+
return 'esc';
|
|
26
|
+
if (data === '\t')
|
|
27
|
+
return 'tab';
|
|
28
|
+
if (data === '\x1b[C')
|
|
29
|
+
return 'right';
|
|
30
|
+
if (data === '\x1b[D')
|
|
31
|
+
return 'left';
|
|
32
|
+
if (data === '\x7f' || data === '\b')
|
|
33
|
+
return 'backspace';
|
|
34
|
+
if (data === '\x03')
|
|
35
|
+
return 'ctrl-c';
|
|
36
|
+
// single printable char (no modifiers) feeds the typeahead
|
|
37
|
+
if (data.length === 1 && data >= ' ')
|
|
38
|
+
return 'char:' + data;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
export function reducePicker(state, key) {
|
|
42
|
+
const next = { ...state, rows: state.rows };
|
|
43
|
+
if (key.startsWith('char:')) {
|
|
44
|
+
next.query += key.slice(5);
|
|
45
|
+
next.selected = 0;
|
|
46
|
+
const vis = visibleRows(next);
|
|
47
|
+
return {
|
|
48
|
+
state: next,
|
|
49
|
+
effect: vis.length === 0 && next.nextCursor ? { kind: 'search-more' } : { kind: 'none' },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
switch (key) {
|
|
53
|
+
case 'backspace':
|
|
54
|
+
if (next.query)
|
|
55
|
+
next.query = next.query.slice(0, -1);
|
|
56
|
+
next.selected = 0;
|
|
57
|
+
return { state: next, effect: { kind: 'none' } };
|
|
58
|
+
case 'up': {
|
|
59
|
+
const vis = visibleRows(next);
|
|
60
|
+
if (vis.length === 0)
|
|
61
|
+
return { state: next, effect: { kind: 'none' } };
|
|
62
|
+
next.selected = Math.max(0, next.selected - 1);
|
|
63
|
+
return { state: next, effect: nearEnd(next) };
|
|
64
|
+
}
|
|
65
|
+
case 'down': {
|
|
66
|
+
const vis = visibleRows(next);
|
|
67
|
+
if (vis.length === 0)
|
|
68
|
+
return { state: next, effect: next.nextCursor ? { kind: 'search-more' } : { kind: 'none' } };
|
|
69
|
+
next.selected = Math.min(vis.length - 1, next.selected + 1);
|
|
70
|
+
return { state: next, effect: nearEnd(next) };
|
|
71
|
+
}
|
|
72
|
+
case 'tab':
|
|
73
|
+
next.focus = next.focus === 0 ? 1 : 0;
|
|
74
|
+
return { state: next, effect: { kind: 'none' } };
|
|
75
|
+
case 'left':
|
|
76
|
+
case 'right': {
|
|
77
|
+
if (next.focus === 0)
|
|
78
|
+
next.cwdOnly = key === 'left'; // left -> Cwd, right -> All
|
|
79
|
+
else
|
|
80
|
+
next.sort = next.sort === 'updated' ? 'created' : 'updated';
|
|
81
|
+
next.selected = 0;
|
|
82
|
+
next.rows = [];
|
|
83
|
+
next.nextCursor = null;
|
|
84
|
+
next.loading = true;
|
|
85
|
+
next.initial = true;
|
|
86
|
+
return { state: next, effect: { kind: 'reload' } };
|
|
87
|
+
}
|
|
88
|
+
case 'enter': {
|
|
89
|
+
const vis = visibleRows(next);
|
|
90
|
+
if (vis.length === 0)
|
|
91
|
+
return { state: next, effect: { kind: 'none' } };
|
|
92
|
+
return { state: next, effect: { kind: 'accept', row: vis[Math.min(next.selected, vis.length - 1)] } };
|
|
93
|
+
}
|
|
94
|
+
case 'esc':
|
|
95
|
+
// codex: query non-empty -> clear it; empty -> exit
|
|
96
|
+
if (next.query) {
|
|
97
|
+
next.query = '';
|
|
98
|
+
next.selected = 0;
|
|
99
|
+
return { state: next, effect: { kind: 'none' } };
|
|
100
|
+
}
|
|
101
|
+
return { state: next, effect: { kind: 'close' } };
|
|
102
|
+
case 'ctrl-c':
|
|
103
|
+
return { state: next, effect: { kind: 'close' } };
|
|
104
|
+
default:
|
|
105
|
+
return { state: next, effect: { kind: 'none' } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function nearEnd(state) {
|
|
109
|
+
const vis = visibleRows(state);
|
|
110
|
+
if (state.nextCursor && !state.loading && state.selected >= vis.length - LOAD_NEAR)
|
|
111
|
+
return { kind: 'load-more' };
|
|
112
|
+
return { kind: 'none' };
|
|
113
|
+
}
|
|
114
|
+
/** "2m" / "3h" / "4d" relative time for the row tail. */
|
|
115
|
+
export function relTime(iso, now = Date.now()) {
|
|
116
|
+
const ms = now - Date.parse(iso);
|
|
117
|
+
if (!Number.isFinite(ms))
|
|
118
|
+
return '';
|
|
119
|
+
const m = Math.floor(ms / 60_000);
|
|
120
|
+
if (m < 1)
|
|
121
|
+
return 'now';
|
|
122
|
+
if (m < 60)
|
|
123
|
+
return m + 'm';
|
|
124
|
+
const h = Math.round(m / 60);
|
|
125
|
+
if (h < 24)
|
|
126
|
+
return h + 'h';
|
|
127
|
+
return Math.round(h / 24) + 'd';
|
|
128
|
+
}
|
|
129
|
+
/** One list row: `❯ 2026-09-12T17-30 run a harmless command 2h`.
|
|
130
|
+
* The id prefix (filename timestamp - codex shows the same) is the anchor
|
|
131
|
+
* the eye scans by; the title truncates into the remaining width. */
|
|
132
|
+
export function formatRow(row, selected, width) {
|
|
133
|
+
const marker = selected ? '❯ ' : ' ';
|
|
134
|
+
const id = row.id.slice(0, 16);
|
|
135
|
+
const title = (row.title || '').replace(/\s+/g, ' ').trim();
|
|
136
|
+
const ago = relTime(row.updatedAt);
|
|
137
|
+
const titleWidth = Math.max(8, width - marker.length - id.length - 2 - ago.length - 1);
|
|
138
|
+
const shown = title.length > titleWidth ? title.slice(0, titleWidth - 1) + '…' : title.padEnd(titleWidth);
|
|
139
|
+
return `${marker}${id} ${shown} ${ago}`;
|
|
140
|
+
}
|
|
141
|
+
/** The search line: `query_` plus the toolbar on the right
|
|
142
|
+
* (codex: `Filter:[Cwd] Sort:[Updated]`, focused control highlighted). */
|
|
143
|
+
export function toolbarLine(state, labels, width, paint) {
|
|
144
|
+
const f = `${labels.filter}:[${state.cwdOnly ? labels.cwd : labels.all}]`;
|
|
145
|
+
const s = `${labels.sort}:[${state.sort === 'updated' ? labels.updated : labels.created}]`;
|
|
146
|
+
const bar = `${f} ${s}`;
|
|
147
|
+
const room = Math.max(0, width - bar.length - 2);
|
|
148
|
+
const q = (state.query + '_').slice(0, room);
|
|
149
|
+
const pad = ' '.repeat(Math.max(1, room - q.length));
|
|
150
|
+
return q + pad + (state.focus === 0 ? paint(f, true) + ' ' + paint(s, false) : paint(f, false) + ' ' + paint(s, true));
|
|
151
|
+
}
|
package/dist/tui.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SessionSummary } from '@hmharness/kernel';
|
|
1
2
|
import { type Locale } from '@hmharness/agent';
|
|
2
3
|
/** desc keys index into Strings (agent i18n); matched by name at runtime. */
|
|
3
4
|
export declare const COMMANDS: Array<{
|
|
@@ -49,8 +50,6 @@ export declare class TuiRuntime {
|
|
|
49
50
|
private modeTag;
|
|
50
51
|
/** rows for the `/model ` picker (configured providers first, set by driver) */
|
|
51
52
|
private modelChoices;
|
|
52
|
-
/** rows for the `/resume ` session picker (id prefix + first user line) */
|
|
53
|
-
private sessionChoices;
|
|
54
53
|
/** Wheel/click handling: NO mouse reporting by default - select/copy
|
|
55
54
|
* always works and terminals translate the wheel to arrow keys on the
|
|
56
55
|
* alternate screen. Reporting turns on ONLY while a palette is open
|
|
@@ -68,16 +67,26 @@ export declare class TuiRuntime {
|
|
|
68
67
|
name: string;
|
|
69
68
|
desc: string;
|
|
70
69
|
}>): void;
|
|
71
|
-
setSessionChoices(list: Array<{
|
|
72
|
-
name: string;
|
|
73
|
-
desc: string;
|
|
74
|
-
}>): void;
|
|
75
70
|
/** Focus the /model picker (used by the bare `/model` command so the
|
|
76
71
|
* printed list is never a dead end - the live palette opens on it). */
|
|
77
72
|
openModelPicker(): void;
|
|
78
|
-
/**
|
|
79
|
-
*
|
|
80
|
-
|
|
73
|
+
/** While open the picker owns every key/wheel event and the whole frame -
|
|
74
|
+
* the codex alt-screen picker contract. resolve fires on Enter (resume)
|
|
75
|
+
* or Esc/Ctrl-C (close). */
|
|
76
|
+
private resumeModal;
|
|
77
|
+
openResumePicker(): Promise<{
|
|
78
|
+
kind: 'resume';
|
|
79
|
+
row: SessionSummary;
|
|
80
|
+
} | {
|
|
81
|
+
kind: 'close';
|
|
82
|
+
} | null>;
|
|
83
|
+
/** Fetch one listSessions page (reset=true restarts from page 1 after a
|
|
84
|
+
* toolbar change). Dedupes by id, drops stale responses by token. */
|
|
85
|
+
private loadPickerPage;
|
|
86
|
+
private pickerInput;
|
|
87
|
+
/** Full-frame picker layout (codex draw_picker): title / search+toolbar /
|
|
88
|
+
* list window / two hint lines / position footer. */
|
|
89
|
+
private renderResumePicker;
|
|
81
90
|
/** Run the highlighted palette row (shared by Enter and palette clicks);
|
|
82
91
|
* with no palette open it just submits the typed input. */
|
|
83
92
|
private pickHighlighted;
|
|
@@ -101,9 +110,10 @@ export declare class TuiRuntime {
|
|
|
101
110
|
* while it shows, clicks choose its rows and the wheel drives its
|
|
102
111
|
* selection; drag-select resumes the moment it closes. */
|
|
103
112
|
private syncMouseReporting;
|
|
104
|
-
/** The palette data source: `/model ` opens the model picker,
|
|
105
|
-
*
|
|
106
|
-
*
|
|
113
|
+
/** The palette data source: `/model ` opens the model picker, otherwise
|
|
114
|
+
* slash commands. (/resume submits straight through to the driver, which
|
|
115
|
+
* opens the Codex-style full-frame picker - resume-picker.ts.) Rows are
|
|
116
|
+
* {name, desc} so both share one renderer, keyboard and click machinery. */
|
|
107
117
|
private panelItems;
|
|
108
118
|
configure(model: string, cwdName: string, skillCount: number, locale: Locale, version?: string): void;
|
|
109
119
|
destroy(): void;
|
|
@@ -129,10 +139,8 @@ export declare class TuiRuntime {
|
|
|
129
139
|
private totalLines;
|
|
130
140
|
private onKey;
|
|
131
141
|
render(): void;
|
|
142
|
+
private flushFrame;
|
|
132
143
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
* event is always near the head, so the peek is O(64KB) per session. */
|
|
137
|
-
export declare function firstUserLinePeek(file: string): Promise<string>;
|
|
138
|
-
export declare function tui(yes: boolean, noWeb?: boolean): Promise<void>;
|
|
144
|
+
export declare function tui(yes: boolean, noWeb?: boolean, opts?: {
|
|
145
|
+
resumeAtStart?: boolean;
|
|
146
|
+
}): Promise<void>;
|
package/dist/tui.js
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* Not a TTY? Prints a pointer to the plain REPL instead.
|
|
12
12
|
*/
|
|
13
13
|
import { stdin, stdout } from 'node:process';
|
|
14
|
-
import { basename
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
|
-
import {
|
|
17
|
-
import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders } from '@hmharness/kernel';
|
|
16
|
+
import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders, latestSession, listSessions, loadTranscript } from '@hmharness/kernel';
|
|
18
17
|
import { listDrafts, listSkills, runBench, runEvolution } from '@hmharness/evolution';
|
|
19
18
|
import { buildRegistry, runAgentTask, strings } from '@hmharness/agent';
|
|
20
19
|
import { ensureWebDaemon, DEFAULT_WEB_PORT } from "./web-daemon.js";
|
|
20
|
+
import { formatRow, initialPickerState, pickerKey, reducePicker, toolbarLine, visibleRows } from "./resume-picker.js";
|
|
21
21
|
/** installed version, shown in the TUI header (v0.4.0) so users always
|
|
22
22
|
* know which build they are talking to - resolves in both src/ and dist/ */
|
|
23
23
|
const HMH_VERSION = (() => {
|
|
@@ -171,8 +171,6 @@ export class TuiRuntime {
|
|
|
171
171
|
modeTag = '';
|
|
172
172
|
/** rows for the `/model ` picker (configured providers first, set by driver) */
|
|
173
173
|
modelChoices = [];
|
|
174
|
-
/** rows for the `/resume ` session picker (id prefix + first user line) */
|
|
175
|
-
sessionChoices = [];
|
|
176
174
|
/** Wheel/click handling: NO mouse reporting by default - select/copy
|
|
177
175
|
* always works and terminals translate the wheel to arrow keys on the
|
|
178
176
|
* alternate screen. Reporting turns on ONLY while a palette is open
|
|
@@ -201,10 +199,6 @@ export class TuiRuntime {
|
|
|
201
199
|
this.modelChoices = list;
|
|
202
200
|
this.dirty = true;
|
|
203
201
|
}
|
|
204
|
-
setSessionChoices(list) {
|
|
205
|
-
this.sessionChoices = list;
|
|
206
|
-
this.dirty = true;
|
|
207
|
-
}
|
|
208
202
|
/** Focus the /model picker (used by the bare `/model` command so the
|
|
209
203
|
* printed list is never a dead end - the live palette opens on it). */
|
|
210
204
|
openModelPicker() {
|
|
@@ -213,14 +207,115 @@ export class TuiRuntime {
|
|
|
213
207
|
this.cmdIdx = 0;
|
|
214
208
|
this.dirty = true;
|
|
215
209
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
210
|
+
/* ---------------- Codex-style resume picker modal ---------------- */
|
|
211
|
+
/** While open the picker owns every key/wheel event and the whole frame -
|
|
212
|
+
* the codex alt-screen picker contract. resolve fires on Enter (resume)
|
|
213
|
+
* or Esc/Ctrl-C (close). */
|
|
214
|
+
resumeModal = null;
|
|
215
|
+
openResumePicker() {
|
|
216
|
+
if (this.resumeModal)
|
|
217
|
+
return Promise.resolve(null);
|
|
218
|
+
return new Promise((resolve) => {
|
|
219
|
+
this.resumeModal = { st: initialPickerState('updated', true), resolve, token: 0 };
|
|
220
|
+
this.dirty = true;
|
|
221
|
+
void this.loadPickerPage(undefined, true);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** Fetch one listSessions page (reset=true restarts from page 1 after a
|
|
225
|
+
* toolbar change). Dedupes by id, drops stale responses by token. */
|
|
226
|
+
async loadPickerPage(cursor, reset) {
|
|
227
|
+
const modal = this.resumeModal;
|
|
228
|
+
if (!modal)
|
|
229
|
+
return;
|
|
230
|
+
const token = ++modal.token;
|
|
231
|
+
const st0 = reset
|
|
232
|
+
? { ...modal.st, rows: [], selected: 0, nextCursor: null, loading: true, initial: true }
|
|
233
|
+
: { ...modal.st, loading: true };
|
|
234
|
+
modal.st = st0;
|
|
235
|
+
this.dirty = true;
|
|
236
|
+
let page;
|
|
237
|
+
try {
|
|
238
|
+
page = await listSessions(homeDir(), {
|
|
239
|
+
...(reset ? {} : { cursor }),
|
|
240
|
+
sort: st0.sort,
|
|
241
|
+
cwd: st0.cwdOnly ? process.cwd() : null,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
page = { items: [], nextCursor: null, numScanned: 0, reachedScanCap: false };
|
|
246
|
+
}
|
|
247
|
+
const live = this.resumeModal;
|
|
248
|
+
if (!live || live.token !== token)
|
|
249
|
+
return;
|
|
250
|
+
const seen = new Set(reset ? [] : live.st.rows.map((r) => r.id));
|
|
251
|
+
live.st = {
|
|
252
|
+
...live.st,
|
|
253
|
+
rows: [...live.st.rows, ...page.items.filter((i) => !seen.has(i.id))],
|
|
254
|
+
nextCursor: page.nextCursor,
|
|
255
|
+
loading: false,
|
|
256
|
+
initial: false,
|
|
257
|
+
};
|
|
222
258
|
this.dirty = true;
|
|
223
259
|
}
|
|
260
|
+
pickerInput(key) {
|
|
261
|
+
const modal = this.resumeModal;
|
|
262
|
+
if (!modal)
|
|
263
|
+
return;
|
|
264
|
+
const { state, effect } = reducePicker(modal.st, key);
|
|
265
|
+
modal.st = state;
|
|
266
|
+
this.dirty = true;
|
|
267
|
+
if (effect.kind === 'accept') {
|
|
268
|
+
this.resumeModal = null;
|
|
269
|
+
modal.resolve({ kind: 'resume', row: effect.row });
|
|
270
|
+
}
|
|
271
|
+
else if (effect.kind === 'close') {
|
|
272
|
+
this.resumeModal = null;
|
|
273
|
+
modal.resolve({ kind: 'close' });
|
|
274
|
+
}
|
|
275
|
+
else if (effect.kind === 'reload') {
|
|
276
|
+
void this.loadPickerPage(undefined, true);
|
|
277
|
+
}
|
|
278
|
+
else if ((effect.kind === 'load-more' || effect.kind === 'search-more') && !state.loading && state.nextCursor) {
|
|
279
|
+
void this.loadPickerPage(state.nextCursor, false);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Full-frame picker layout (codex draw_picker): title / search+toolbar /
|
|
283
|
+
* list window / two hint lines / position footer. */
|
|
284
|
+
renderResumePicker(frame, W, H) {
|
|
285
|
+
const st = this.resumeModal.st;
|
|
286
|
+
const t = this.t;
|
|
287
|
+
const count = ` ${st.rows.length}${st.loading ? '…' : ''} `;
|
|
288
|
+
frame.push(truncateTo(BOLD(' ' + t.pickerTitle) + ' '.repeat(Math.max(1, W - strWidth(t.pickerTitle) - count.length - 1)) + DIM(count), W));
|
|
289
|
+
frame.push(DIM('─'.repeat(W)));
|
|
290
|
+
frame.push(' ' + toolbarLine(st, {
|
|
291
|
+
filter: t.pickerLabelFilter, sort: t.pickerLabelSort, cwd: t.pickerValCwd, all: t.pickerValAll, updated: t.pickerValUpdated, created: t.pickerValCreated,
|
|
292
|
+
}, W - 2, (s, on) => (on ? CYAN(BOLD(s)) : DIM(s))));
|
|
293
|
+
frame.push(DIM('─'.repeat(W)));
|
|
294
|
+
const vis = visibleRows(st);
|
|
295
|
+
const listH = Math.max(3, H - 8);
|
|
296
|
+
const from = vis.length > listH
|
|
297
|
+
? Math.min(Math.max(0, st.selected - Math.floor(listH / 2)), vis.length - listH)
|
|
298
|
+
: 0;
|
|
299
|
+
if (from > 0)
|
|
300
|
+
frame.push(DIM(' ↑ more'));
|
|
301
|
+
for (let i = 0; i < listH && from + i < vis.length; i++) {
|
|
302
|
+
const sel = from + i === st.selected;
|
|
303
|
+
const plain = truncateTo(formatRow(vis[from + i], sel, W - 1), W - 1);
|
|
304
|
+
frame.push(sel ? '\x1b[7m' + plain + ' '.repeat(Math.max(0, W - 1 - strWidth(plain))) + '\x1b[27m' : ' ' + plain);
|
|
305
|
+
}
|
|
306
|
+
if (st.loading)
|
|
307
|
+
frame.push(DIM(' ' + (vis.length === 0 ? t.pickerLoading : t.pickerMore)));
|
|
308
|
+
else if (vis.length === 0)
|
|
309
|
+
frame.push(DIM(' ' + (st.query ? t.pickerNoMatch : t.pickerEmpty)));
|
|
310
|
+
else if (from + listH < vis.length)
|
|
311
|
+
frame.push(DIM(' ↓ more'));
|
|
312
|
+
frame.push(DIM('─'.repeat(W)));
|
|
313
|
+
frame.push(DIM(truncateTo(' ' + t.pickerHint1, W - 1)));
|
|
314
|
+
frame.push(DIM(truncateTo(' ' + t.pickerHint2, W - 1)));
|
|
315
|
+
const pos = vis.length ? st.selected + 1 : 0;
|
|
316
|
+
const posLine = t.pickerPos(pos, vis.length, vis.length ? String(Math.round((pos / vis.length) * 100)) : '0');
|
|
317
|
+
frame.push(' '.repeat(Math.max(0, W - posLine.length - 1)) + DIM(posLine));
|
|
318
|
+
}
|
|
224
319
|
/** Run the highlighted palette row (shared by Enter and palette clicks);
|
|
225
320
|
* with no palette open it just submits the typed input. */
|
|
226
321
|
pickHighlighted() {
|
|
@@ -229,8 +324,6 @@ export class TuiRuntime {
|
|
|
229
324
|
if (pick) {
|
|
230
325
|
if (this.input.startsWith('/model'))
|
|
231
326
|
this.input = `/model ${pick} `;
|
|
232
|
-
else if (this.input.startsWith('/resume'))
|
|
233
|
-
this.input = `/resume ${pick}`;
|
|
234
327
|
else
|
|
235
328
|
this.input = pick + ' ';
|
|
236
329
|
this.caret = this.input.length;
|
|
@@ -294,9 +387,10 @@ export class TuiRuntime {
|
|
|
294
387
|
this.mouseReported = want;
|
|
295
388
|
stdout.write(want ? '\x1b[?1000h\x1b[?1006h' : '\x1b[?1000l\x1b[?1006l');
|
|
296
389
|
}
|
|
297
|
-
/** The palette data source: `/model ` opens the model picker,
|
|
298
|
-
*
|
|
299
|
-
*
|
|
390
|
+
/** The palette data source: `/model ` opens the model picker, otherwise
|
|
391
|
+
* slash commands. (/resume submits straight through to the driver, which
|
|
392
|
+
* opens the Codex-style full-frame picker - resume-picker.ts.) Rows are
|
|
393
|
+
* {name, desc} so both share one renderer, keyboard and click machinery. */
|
|
300
394
|
panelItems(input) {
|
|
301
395
|
if (input === '/model' || input.startsWith('/model ')) {
|
|
302
396
|
const q = input.slice(6).trim().toLowerCase();
|
|
@@ -307,10 +401,6 @@ export class TuiRuntime {
|
|
|
307
401
|
const all = [...configured, ...rest];
|
|
308
402
|
return q ? all.filter((i) => i.name.toLowerCase().startsWith(q)) : all;
|
|
309
403
|
}
|
|
310
|
-
if (input === '/resume' || input.startsWith('/resume ')) {
|
|
311
|
-
const q = input.slice(7).trim().toLowerCase();
|
|
312
|
-
return q ? this.sessionChoices.filter((i) => i.name.toLowerCase().startsWith(q)) : this.sessionChoices;
|
|
313
|
-
}
|
|
314
404
|
return matchCommands(input).map((c) => ({ name: c.name, desc: String(this.t[c.key]) }));
|
|
315
405
|
}
|
|
316
406
|
configure(model, cwdName, skillCount, locale, version) {
|
|
@@ -337,6 +427,12 @@ export class TuiRuntime {
|
|
|
337
427
|
}
|
|
338
428
|
quit() {
|
|
339
429
|
this.running = false;
|
|
430
|
+
// an open picker never resolves on its own once the TUI exits
|
|
431
|
+
if (this.resumeModal) {
|
|
432
|
+
const resolve = this.resumeModal.resolve;
|
|
433
|
+
this.resumeModal = null;
|
|
434
|
+
resolve({ kind: 'close' });
|
|
435
|
+
}
|
|
340
436
|
this.exitResolve?.();
|
|
341
437
|
}
|
|
342
438
|
/* ---------------- content API ---------------- */
|
|
@@ -471,6 +567,15 @@ export class TuiRuntime {
|
|
|
471
567
|
// matches so navigation never silently dies
|
|
472
568
|
if (/^\x1bO[A-H]$/.test(data))
|
|
473
569
|
data = '\x1b[' + data[2];
|
|
570
|
+
// resume picker modal owns every event while open - keyboard AND wheel
|
|
571
|
+
// (codex: the picker runs its own event loop until it resolves)
|
|
572
|
+
if (this.resumeModal) {
|
|
573
|
+
const wheel = parseWheel(data);
|
|
574
|
+
const key = wheel !== 0 ? (wheel < 0 ? 'up' : 'down') : pickerKey(data);
|
|
575
|
+
if (key !== null)
|
|
576
|
+
this.pickerInput(key);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
474
579
|
// wheel-only mouse routing: 64 = wheel-up, 65 = wheel-down. With
|
|
475
580
|
// button-event mode (1002) everything else - click, drag, release,
|
|
476
581
|
// motion - still belongs to the terminal's native selection.
|
|
@@ -546,16 +651,12 @@ export class TuiRuntime {
|
|
|
546
651
|
// Claude Code's two-stage flow: Enter shows the dialog, arrows/wheel
|
|
547
652
|
// move, a second Enter confirms the highlighted row. The old behavior
|
|
548
653
|
// silently switched to the first model on the very first Enter.
|
|
654
|
+
// (/resume needs no interception: submitting it opens the Codex-style
|
|
655
|
+
// modal via the driver, and '/resume <prefix>' loads directly.)
|
|
549
656
|
if (this.input === '/model') {
|
|
550
657
|
this.openModelPicker();
|
|
551
658
|
return;
|
|
552
659
|
}
|
|
553
|
-
// same two-stage rule for /resume: bare command + Enter opens the
|
|
554
|
-
// session picker (focus moves to the list), never loads row 0 blindly
|
|
555
|
-
if (this.input === '/resume') {
|
|
556
|
-
this.openSessionPicker();
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
660
|
// palette open: Enter runs the highlighted row (a command, or a
|
|
560
661
|
// /model target), not the raw input; without a palette it submits
|
|
561
662
|
this.pickHighlighted();
|
|
@@ -566,9 +667,7 @@ export class TuiRuntime {
|
|
|
566
667
|
if (hits.length) {
|
|
567
668
|
this.input = this.input.startsWith('/model')
|
|
568
669
|
? `/model ${hits[Math.min(this.cmdIdx, hits.length - 1)].name} `
|
|
569
|
-
: this.
|
|
570
|
-
? `/resume ${hits[Math.min(this.cmdIdx, hits.length - 1)].name}`
|
|
571
|
-
: hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
|
|
670
|
+
: hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
|
|
572
671
|
this.caret = this.input.length;
|
|
573
672
|
this.cmdIdx = 0;
|
|
574
673
|
this.dirty = true;
|
|
@@ -695,6 +794,11 @@ export class TuiRuntime {
|
|
|
695
794
|
const W = stdout.columns || 100;
|
|
696
795
|
const H = stdout.rows || 30;
|
|
697
796
|
const frame = [];
|
|
797
|
+
if (this.resumeModal) {
|
|
798
|
+
this.renderResumePicker(frame, W, H);
|
|
799
|
+
this.flushFrame(frame, H);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
698
802
|
// modal mouse reporting follows the palette (see syncMouseReporting);
|
|
699
803
|
// click rows are re-recorded every frame because screen positions move
|
|
700
804
|
this.syncMouseReporting();
|
|
@@ -882,6 +986,9 @@ export class TuiRuntime {
|
|
|
882
986
|
// pending wrap a full-width line leaves behind — "\x1b[B" joins would skip
|
|
883
987
|
// a row on immediate-wrap terminals (conhost) and push the frame past the
|
|
884
988
|
// last line, scrolling the header away and clipping the input box.
|
|
989
|
+
this.flushFrame(frame, H);
|
|
990
|
+
}
|
|
991
|
+
flushFrame(frame, H) {
|
|
885
992
|
const visible = frame.slice(0, H);
|
|
886
993
|
let out = '';
|
|
887
994
|
for (let i = 0; i < visible.length; i++)
|
|
@@ -890,35 +997,7 @@ export class TuiRuntime {
|
|
|
890
997
|
}
|
|
891
998
|
}
|
|
892
999
|
/* ---------------- driver ---------------- */
|
|
893
|
-
|
|
894
|
-
* 64KB, scan lines for the first user event. With hundreds of sessions a
|
|
895
|
-
* full loadTranscript per row would stall the picker for seconds; the user
|
|
896
|
-
* event is always near the head, so the peek is O(64KB) per session. */
|
|
897
|
-
export async function firstUserLinePeek(file) {
|
|
898
|
-
try {
|
|
899
|
-
const fh = await fopen(file, 'r');
|
|
900
|
-
try {
|
|
901
|
-
const buf = Buffer.alloc(65_536);
|
|
902
|
-
const { bytesRead } = await fh.read(buf, 0, 65_536, 0);
|
|
903
|
-
for (const line of buf.toString('utf8', 0, bytesRead).split('\n')) {
|
|
904
|
-
if (!line.includes('"user"'))
|
|
905
|
-
continue;
|
|
906
|
-
try {
|
|
907
|
-
const ev = JSON.parse(line);
|
|
908
|
-
if (ev.t === 'user' && typeof ev.text === 'string')
|
|
909
|
-
return ev.text;
|
|
910
|
-
}
|
|
911
|
-
catch { /* partial line at the buffer edge - no preview */ }
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
finally {
|
|
915
|
-
await fh.close();
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
catch { /* unreadable file - no preview */ }
|
|
919
|
-
return '';
|
|
920
|
-
}
|
|
921
|
-
export async function tui(yes, noWeb = false) {
|
|
1000
|
+
export async function tui(yes, noWeb = false, opts = {}) {
|
|
922
1001
|
let cfg = await loadConfig();
|
|
923
1002
|
let autoApprove = yes || cfg.approval === 'auto';
|
|
924
1003
|
if (!stdin.isTTY) {
|
|
@@ -950,6 +1029,16 @@ export async function tui(yes, noWeb = false) {
|
|
|
950
1029
|
void notifyUpdate(home, current, (latest) => rt.addText(`↑ ${t.updateHint(latest)}`, 'dim'));
|
|
951
1030
|
}
|
|
952
1031
|
let history = [];
|
|
1032
|
+
// Codex thread semantics: one rollout file per conversation. The first task
|
|
1033
|
+
// creates it; every later turn (and everything after /resume) appends to it.
|
|
1034
|
+
let currentSessionId;
|
|
1035
|
+
// `hmh resume` startup: the picker comes up before the first prompt (codex
|
|
1036
|
+
// `codex resume` behavior); Esc leaves a fresh conversation
|
|
1037
|
+
if (opts.resumeAtStart) {
|
|
1038
|
+
const pick = await rt.openResumePicker();
|
|
1039
|
+
if (pick?.kind === 'resume')
|
|
1040
|
+
await resumeInto(pick.row.file);
|
|
1041
|
+
}
|
|
953
1042
|
// Task queue: new submissions during a running task are queued (not
|
|
954
1043
|
// rejected, not run concurrently — sequential execution preserves history
|
|
955
1044
|
// integrity). Slash commands still run immediately (they're quick).
|
|
@@ -1008,6 +1097,7 @@ export async function tui(yes, noWeb = false) {
|
|
|
1008
1097
|
cfg,
|
|
1009
1098
|
yes: autoApprove,
|
|
1010
1099
|
resumeMessages: history,
|
|
1100
|
+
sessionId: currentSessionId,
|
|
1011
1101
|
signal: currentAbort.signal,
|
|
1012
1102
|
approvalAsk: (name, args) => rt.requestApproval(name, args),
|
|
1013
1103
|
events: {
|
|
@@ -1047,6 +1137,7 @@ export async function tui(yes, noWeb = false) {
|
|
|
1047
1137
|
},
|
|
1048
1138
|
});
|
|
1049
1139
|
rt.setBusy(false);
|
|
1140
|
+
currentSessionId = result.sessionId;
|
|
1050
1141
|
rt.setStatus(`↑${result.usage.promptTokens} ↓${result.usage.completionTokens} tok · ${result.turns} turns · ${result.toolUses} tools`);
|
|
1051
1142
|
history = [...history, { role: 'user', content: line }, ...result.messages.slice(history.length + 2)];
|
|
1052
1143
|
}
|
|
@@ -1058,6 +1149,49 @@ export async function tui(yes, noWeb = false) {
|
|
|
1058
1149
|
currentAbort = null;
|
|
1059
1150
|
}
|
|
1060
1151
|
}
|
|
1152
|
+
/** Load a rollout into the conversation: `history` for the model, a tail
|
|
1153
|
+
* window for the eye, and currentSessionId so the next turn APPENDS to
|
|
1154
|
+
* the same rollout (codex Resume semantics - one thread, one file). */
|
|
1155
|
+
async function resumeInto(file) {
|
|
1156
|
+
const tr = await loadTranscript(file);
|
|
1157
|
+
if (!tr || tr.messages.length === 0) {
|
|
1158
|
+
rt.addText(t.cmdResumeNotFound(tr?.id ?? file), 'err');
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
history = tr.messages;
|
|
1162
|
+
currentSessionId = tr.id;
|
|
1163
|
+
rt.clearScreen();
|
|
1164
|
+
// long sessions render from the tail so the visible window stays usable
|
|
1165
|
+
// while the complete transcript lives in `history` for the model
|
|
1166
|
+
const MAX_RENDER = 80;
|
|
1167
|
+
const msgs = tr.messages;
|
|
1168
|
+
const skipped = Math.max(0, msgs.length - MAX_RENDER);
|
|
1169
|
+
rt.addText('--- resumed ' + tr.id + ' · ' + msgs.length + ' messages'
|
|
1170
|
+
+ (skipped > 0 ? ' (' + skipped + ' earlier kept in context, not shown)' : '')
|
|
1171
|
+
+ ' ---', 'dim');
|
|
1172
|
+
for (const m of (skipped > 0 ? msgs.slice(skipped) : msgs)) {
|
|
1173
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
1174
|
+
if (m.role === 'user') {
|
|
1175
|
+
rt.addUser(text.replace(/\n+/g, ' ').slice(0, 400));
|
|
1176
|
+
}
|
|
1177
|
+
else if (m.role === 'assistant') {
|
|
1178
|
+
const calls = m.tool_calls ?? [];
|
|
1179
|
+
if (text.trim())
|
|
1180
|
+
rt.addText(text.slice(0, 2000));
|
|
1181
|
+
for (const c of calls)
|
|
1182
|
+
rt.addText('● ' + CYAN(String(c.function?.name ?? 'tool')) + DIM(' …'), 'dim');
|
|
1183
|
+
}
|
|
1184
|
+
else if (m.role === 'tool') {
|
|
1185
|
+
const first = text.split('\n').find((l) => l.trim()) ?? '';
|
|
1186
|
+
if (first)
|
|
1187
|
+
rt.addText(' ' + DIM('⎿ ' + first.trim().slice(0, 100)), 'dim');
|
|
1188
|
+
}
|
|
1189
|
+
else if (m.role === 'system' && text && !text.startsWith('[context pruned')) {
|
|
1190
|
+
rt.addText(DIM(text.slice(0, 300)), 'dim');
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
rt.addText(t.cmdResumeLoaded(tr.messages.length), 'dim');
|
|
1194
|
+
}
|
|
1061
1195
|
async function handleLine(line) {
|
|
1062
1196
|
if (line === '/exit' || line === '/quit') {
|
|
1063
1197
|
rt.destroy();
|
|
@@ -1087,8 +1221,12 @@ export async function tui(yes, noWeb = false) {
|
|
|
1087
1221
|
rt.addText(COMMANDS.map((c) => ' ' + c.name.padEnd(11) + ' ' + String(t[c.key])).join('\n'), 'dim');
|
|
1088
1222
|
return;
|
|
1089
1223
|
}
|
|
1224
|
+
// /clear = new thread (codex /new): blank screen, drop the in-memory
|
|
1225
|
+
// transcript AND start a fresh rollout on the next task
|
|
1090
1226
|
if (line === '/clear') {
|
|
1091
1227
|
rt.clearScreen();
|
|
1228
|
+
history = [];
|
|
1229
|
+
currentSessionId = undefined;
|
|
1092
1230
|
return;
|
|
1093
1231
|
}
|
|
1094
1232
|
if (line === '/status') {
|
|
@@ -1123,76 +1261,26 @@ export async function tui(yes, noWeb = false) {
|
|
|
1123
1261
|
}
|
|
1124
1262
|
if (line === '/resume' || line.startsWith('/resume ')) {
|
|
1125
1263
|
const arg = line.slice(8).trim();
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
//
|
|
1129
|
-
// challenge: "不应该是所有历史会话吗"). Previews use the 64KB peek,
|
|
1130
|
-
// never a full parse, so hundreds of rows still open instantly; the
|
|
1131
|
-
// palette scrolls and head-prefix filtering narrows fast.
|
|
1132
|
-
let files = [];
|
|
1133
|
-
try {
|
|
1134
|
-
files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl'));
|
|
1135
|
-
}
|
|
1136
|
-
catch { /* none */ }
|
|
1137
|
-
files.sort();
|
|
1138
|
-
const recent = files.reverse();
|
|
1139
|
-
const rows = [];
|
|
1140
|
-
for (const f of recent) {
|
|
1141
|
-
const firstUser = await firstUserLinePeek(join(home, 'sessions', f));
|
|
1142
|
-
rows.push({ name: f.slice(0, 18), desc: (firstUser || '(无预览)').replace(/\n/g, ' ').slice(0, 56) });
|
|
1143
|
-
}
|
|
1144
|
-
rt.setSessionChoices(rows);
|
|
1264
|
+
// bare /resume opens the Codex-style full-frame picker (typeahead,
|
|
1265
|
+
// cwd filter, sort toolbar, lazy pages). It is a modal: it cannot share
|
|
1266
|
+
// the screen with a running task's streaming output.
|
|
1145
1267
|
if (!arg) {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
// reported: "上下键无法选择")
|
|
1149
|
-
if (rows.length === 0) {
|
|
1150
|
-
rt.addText(t.cmdResumeNone, 'dim');
|
|
1268
|
+
if (taskRunning) {
|
|
1269
|
+
rt.addText('task running - stop it first (empty Enter), then /resume', 'dim');
|
|
1151
1270
|
return;
|
|
1152
1271
|
}
|
|
1153
|
-
rt.
|
|
1272
|
+
const pick = await rt.openResumePicker();
|
|
1273
|
+
if (!pick || pick.kind !== 'resume')
|
|
1274
|
+
return;
|
|
1275
|
+
await resumeInto(pick.row.file);
|
|
1154
1276
|
return;
|
|
1155
1277
|
}
|
|
1156
1278
|
const file = await latestSession(home, arg);
|
|
1157
|
-
|
|
1158
|
-
if (!tr || tr.messages.length === 0) {
|
|
1279
|
+
if (!file) {
|
|
1159
1280
|
rt.addText(t.cmdResumeNotFound(arg), 'err');
|
|
1160
1281
|
return;
|
|
1161
1282
|
}
|
|
1162
|
-
|
|
1163
|
-
rt.clearScreen();
|
|
1164
|
-
// Render the FULL session window, not just the first line (user
|
|
1165
|
-
// feedback: "恢复的应该是整个会话窗口,而不仅仅是片段"). Long sessions
|
|
1166
|
-
// are bounded from the tail so the visible window stays usable while
|
|
1167
|
-
// the complete transcript still lives in `history` for the model.
|
|
1168
|
-
const MAX_RENDER = 80;
|
|
1169
|
-
const msgs = tr.messages;
|
|
1170
|
-
const skipped = Math.max(0, msgs.length - MAX_RENDER);
|
|
1171
|
-
rt.addText('--- resumed ' + (tr.id || arg) + ' · ' + msgs.length + ' messages'
|
|
1172
|
-
+ (skipped > 0 ? ' (' + skipped + ' earlier kept in context, not shown)' : '')
|
|
1173
|
-
+ ' ---', 'dim');
|
|
1174
|
-
for (const m of (skipped > 0 ? msgs.slice(skipped) : msgs)) {
|
|
1175
|
-
const text = typeof m.content === 'string' ? m.content : '';
|
|
1176
|
-
if (m.role === 'user') {
|
|
1177
|
-
rt.addUser(text.replace(/\n+/g, ' ').slice(0, 400));
|
|
1178
|
-
}
|
|
1179
|
-
else if (m.role === 'assistant') {
|
|
1180
|
-
const calls = m.tool_calls ?? [];
|
|
1181
|
-
if (text.trim())
|
|
1182
|
-
rt.addText(text.slice(0, 2000));
|
|
1183
|
-
for (const c of calls)
|
|
1184
|
-
rt.addText('● ' + CYAN(String(c.function?.name ?? 'tool')) + DIM(' …'), 'dim');
|
|
1185
|
-
}
|
|
1186
|
-
else if (m.role === 'tool') {
|
|
1187
|
-
const first = text.split('\n').find((l) => l.trim()) ?? '';
|
|
1188
|
-
if (first)
|
|
1189
|
-
rt.addText(' ' + DIM('⎿ ' + first.trim().slice(0, 100)), 'dim');
|
|
1190
|
-
}
|
|
1191
|
-
else if (m.role === 'system' && text && !text.startsWith('[context pruned')) {
|
|
1192
|
-
rt.addText(DIM(text.slice(0, 300)), 'dim');
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
rt.addText(t.cmdResumeLoaded(tr.messages.length), 'dim');
|
|
1283
|
+
await resumeInto(file);
|
|
1196
1284
|
return;
|
|
1197
1285
|
}
|
|
1198
1286
|
if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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,13 +43,13 @@
|
|
|
43
43
|
"build": "tsc -p tsconfig.build.json"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@hmharness/kernel": "0.8.
|
|
46
|
+
"@hmharness/kernel": "0.8.2",
|
|
47
47
|
"@hmharness/observability": "0.7.0",
|
|
48
|
-
"@hmharness/evolution": "0.8.
|
|
48
|
+
"@hmharness/evolution": "0.8.3",
|
|
49
49
|
"@hmharness/domain-harmony": "0.8.0",
|
|
50
50
|
"@hmharness/domain-ops": "0.8.0",
|
|
51
|
-
"@hmharness/agent": "0.8.
|
|
51
|
+
"@hmharness/agent": "0.8.2",
|
|
52
52
|
"@hmharness/evaluation": "0.8.0",
|
|
53
|
-
"@hmharness/web": "0.8.
|
|
53
|
+
"@hmharness/web": "0.8.2"
|
|
54
54
|
}
|
|
55
55
|
}
|