@hncode/hncode 0.4.2 → 0.5.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/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +34 -6
- package/src/tui.js +5219 -792
- package/src/web-public/app.js +7 -0
package/src/file-history.js
CHANGED
|
@@ -175,3 +175,115 @@ export function describeRewind(res) {
|
|
|
175
175
|
if (res.failed.length) parts.push(`${res.failed.length} could not be restored`);
|
|
176
176
|
return parts.join(', ');
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every checkpoint on record, newest turn first, as one flat list.
|
|
181
|
+
*
|
|
182
|
+
* WHY THIS EXISTS BESIDE `rewind`
|
|
183
|
+
* -------------------------------
|
|
184
|
+
* `rewind(n)` is all-or-nothing across whole turns: to get one file back you must also roll
|
|
185
|
+
* back every other file those turns touched. That is the right default (a turn is a unit of
|
|
186
|
+
* intent) and the wrong tool for "I want the version of THIS file from before that one
|
|
187
|
+
* edit". The index already records exactly that — one entry per file per turn, with the
|
|
188
|
+
* content it had — so the list is a rearrangement of existing data, not new bookkeeping.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Array<{turn:number, at:number, path:string, existed:boolean, backup:string,
|
|
191
|
+
* size:number|null, ageMs:number}>} turn is 1-based, counting from the oldest.
|
|
192
|
+
*/
|
|
193
|
+
export function listCheckpoints(sessionId, opts = {}) {
|
|
194
|
+
const doc = readIndex(sessionId);
|
|
195
|
+
const only = opts.path ? path.resolve(opts.path) : null;
|
|
196
|
+
const out = [];
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
for (let i = doc.turns.length - 1; i >= 0; i--) {
|
|
199
|
+
const turn = doc.turns[i];
|
|
200
|
+
for (const f of turn.files) {
|
|
201
|
+
if (only && f.path !== only) continue;
|
|
202
|
+
let size = null;
|
|
203
|
+
if (f.existed && f.backup) {
|
|
204
|
+
try { size = fs.statSync(f.backup).size; } catch { size = null; }
|
|
205
|
+
}
|
|
206
|
+
out.push({
|
|
207
|
+
// `index` is 1-based over the whole list, newest first — the number the panel
|
|
208
|
+
// prints and `restore <n>` accepts. Assigned here rather than derived from `turn`
|
|
209
|
+
// so a row keeps its identity even after another turn is recorded.
|
|
210
|
+
index: 0,
|
|
211
|
+
turn: i + 1,
|
|
212
|
+
at: turn.at,
|
|
213
|
+
path: f.path,
|
|
214
|
+
existed: f.existed !== false,
|
|
215
|
+
backup: f.backup || '',
|
|
216
|
+
size,
|
|
217
|
+
ageMs: Math.max(0, now - (turn.at || now)),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
out.forEach((c, i) => { c.index = i + 1; });
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Restore ONE file from one checkpoint, leaving every other file and the turn index alone.
|
|
227
|
+
*
|
|
228
|
+
* The index is deliberately NOT truncated: this is a surgical restore, not a rewind, so the
|
|
229
|
+
* remaining checkpoints stay valid. A file that did not exist at that checkpoint is deleted,
|
|
230
|
+
* which is what "restore" means for a file the agent created.
|
|
231
|
+
*
|
|
232
|
+
* @returns {{ok: boolean, action?: 'restored'|'deleted', error?: string}}
|
|
233
|
+
*/
|
|
234
|
+
export function restoreCheckpoint(sessionId, entry) {
|
|
235
|
+
const target = entry && entry.path;
|
|
236
|
+
if (!target) return { ok: false, error: 'no path on that checkpoint' };
|
|
237
|
+
try {
|
|
238
|
+
if (entry.existed === false) {
|
|
239
|
+
fs.rmSync(target, { force: true });
|
|
240
|
+
return { ok: true, action: 'deleted' };
|
|
241
|
+
}
|
|
242
|
+
if (!entry.backup) return { ok: false, error: 'that checkpoint has no content copy' };
|
|
243
|
+
// Read BEFORE creating the directory: a missing backup must not leave an empty
|
|
244
|
+
// directory behind where there was none.
|
|
245
|
+
const content = fs.readFileSync(entry.backup);
|
|
246
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
247
|
+
fs.writeFileSync(target, content);
|
|
248
|
+
return { ok: true, action: 'restored' };
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A `+N -M` size delta for a checkpoint against the current file, or null when either side
|
|
255
|
+
* is unavailable. Cheap: it reads only the backup (the current file is stat'ed). */
|
|
256
|
+
export function checkpointDelta(entry) {
|
|
257
|
+
if (!entry || !entry.existed || !entry.backup) return null;
|
|
258
|
+
try {
|
|
259
|
+
const before = fs.readFileSync(entry.backup, 'utf8').split(/\r?\n/).length;
|
|
260
|
+
const after = fs.readFileSync(entry.path, 'utf8').split(/\r?\n/).length;
|
|
261
|
+
return { before, after, lines: after - before };
|
|
262
|
+
} catch { return null; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Lines for the /checkpoints panel: one row per checkpoint, grouped visually by turn. */
|
|
266
|
+
export function describeCheckpoints(sessionId, workspace) {
|
|
267
|
+
const all = listCheckpoints(sessionId);
|
|
268
|
+
if (!all.length) {
|
|
269
|
+
return ['No checkpoints recorded for this session yet.', '',
|
|
270
|
+
'A checkpoint is taken the first time the agent changes a file in a turn,',
|
|
271
|
+
'so this fills in as you work.'];
|
|
272
|
+
}
|
|
273
|
+
const lines = [`Checkpoints (${all.length}) across ${new Set(all.map((c) => c.turn)).size} turn(s)`, ''];
|
|
274
|
+
let lastTurn = null;
|
|
275
|
+
for (const c of all) {
|
|
276
|
+
if (c.turn !== lastTurn) {
|
|
277
|
+
lastTurn = c.turn;
|
|
278
|
+
const when = c.at ? new Date(c.at).toISOString().slice(0, 16).replace('T', ' ') : '?';
|
|
279
|
+
lines.push(`turn ${c.turn} ${when}`);
|
|
280
|
+
}
|
|
281
|
+
const rel = workspace && String(c.path).startsWith(workspace)
|
|
282
|
+
? path.relative(workspace, c.path).split(path.sep).join('/')
|
|
283
|
+
: c.path;
|
|
284
|
+
const what = c.existed ? `${c.size == null ? '?' : c.size} B` : 'created here';
|
|
285
|
+
lines.push(` ${String(c.index).padStart(3)} ${what.padEnd(12)} ${rel}`);
|
|
286
|
+
}
|
|
287
|
+
lines.push('', 'Restore one with /checkpoints restore <n>');
|
|
288
|
+
return lines;
|
|
289
|
+
}
|
package/src/file-tree.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// File tree browser — a navigable view of the workspace, opened with /files.
|
|
2
|
+
//
|
|
3
|
+
// WHY A TREE AND NOT A PICKER
|
|
4
|
+
// ---------------------------
|
|
5
|
+
// The picker is a flat, filtered list: good for "find me this one file" and bad for
|
|
6
|
+
// "what is in here". Reading a project's shape is a question the flat list cannot answer,
|
|
7
|
+
// because it has no directories — every path is a string, and the structure is only
|
|
8
|
+
// visible by reading the prefixes. This module renders the structure itself.
|
|
9
|
+
//
|
|
10
|
+
// WHY IT DOES NOT RE-IMPLEMENT THE WALK
|
|
11
|
+
// -------------------------------------
|
|
12
|
+
// `tools/ignore.js` already walks a tree honouring .gitignore/.hncodeignore, including
|
|
13
|
+
// negation patterns and subtree pruning, and it is what Grep and Glob use. A second walker
|
|
14
|
+
// here would drift: the browser would show files the tools refuse to search, which is
|
|
15
|
+
// worse than no browser. So this module takes a LIST OF PATHS and only arranges it.
|
|
16
|
+
//
|
|
17
|
+
// WHY THE DIRECTORIES ARE DERIVED, NOT WALKED
|
|
18
|
+
// -------------------------------------------
|
|
19
|
+
// `walkFiles` returns files. The directories in between are reconstructed from the paths,
|
|
20
|
+
// which means the tree can never disagree with the list it came from — and it costs one
|
|
21
|
+
// pass over a flat array rather than a second filesystem traversal.
|
|
22
|
+
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { C } from './colors.js';
|
|
25
|
+
import { visualWidth } from './term.js';
|
|
26
|
+
|
|
27
|
+
const ELLIPSIS = '\u2026';
|
|
28
|
+
const ESC = '\x1b';
|
|
29
|
+
|
|
30
|
+
function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
|
|
31
|
+
function col(text, c) { return c + String(text) + C.reset; }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a directory tree from a flat list of absolute paths.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} files absolute paths, as `walkFiles` returns them
|
|
37
|
+
* @param {string} root the workspace root they are relative to
|
|
38
|
+
* @returns {{name:string, rel:string, dir:boolean, children:Array}} the root node
|
|
39
|
+
*
|
|
40
|
+
* Children are sorted DIRECTORIES FIRST, then names, which is what makes a tree readable:
|
|
41
|
+
* a mixed sort buries directories among files and the structure stops being visible.
|
|
42
|
+
*/
|
|
43
|
+
export function buildTree(files, root) {
|
|
44
|
+
const base = path.resolve(root);
|
|
45
|
+
const rootNode = { name: path.basename(base) || base, rel: '', dir: true, children: [] };
|
|
46
|
+
// One map per directory level, keyed by relative path, so a directory is created once
|
|
47
|
+
// no matter how many files sit under it.
|
|
48
|
+
const dirs = new Map([['', rootNode]]);
|
|
49
|
+
|
|
50
|
+
function ensureDir(rel) {
|
|
51
|
+
if (dirs.has(rel)) return dirs.get(rel);
|
|
52
|
+
const parentRel = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
|
|
53
|
+
const parent = ensureDir(parentRel);
|
|
54
|
+
const node = { name: rel.slice(rel.lastIndexOf('/') + 1), rel, dir: true, children: [] };
|
|
55
|
+
parent.children.push(node);
|
|
56
|
+
dirs.set(rel, node);
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const abs of files) {
|
|
61
|
+
let rel = path.relative(base, abs);
|
|
62
|
+
if (!rel || rel.startsWith('..')) continue; // outside the root: not ours to show
|
|
63
|
+
rel = rel.split(path.sep).join('/');
|
|
64
|
+
const parentRel = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
|
|
65
|
+
const parent = ensureDir(parentRel);
|
|
66
|
+
parent.children.push({
|
|
67
|
+
name: rel.slice(rel.lastIndexOf('/') + 1), rel, dir: false, children: [],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
sortTree(rootNode);
|
|
71
|
+
return rootNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sortTree(node) {
|
|
75
|
+
if (!node.children.length) return;
|
|
76
|
+
node.children.sort((a, b) => {
|
|
77
|
+
if (a.dir !== b.dir) return a.dir ? -1 : 1;
|
|
78
|
+
return a.name.localeCompare(b.name);
|
|
79
|
+
});
|
|
80
|
+
for (const c of node.children) if (c.dir) sortTree(c);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Flatten the tree to the rows the panel draws, honouring which directories are open.
|
|
85
|
+
*
|
|
86
|
+
* `expanded` is a Set of relative directory paths. It is the caller's state, not this
|
|
87
|
+
* module's: a browser that owned its own open/closed set could not be reopened where the
|
|
88
|
+
* user left it, and could not be driven by a test.
|
|
89
|
+
*
|
|
90
|
+
* @returns {Array<{node:object, depth:number, last:boolean, prefix:string}>}
|
|
91
|
+
* `prefix` carries the box-drawing gutter (`│ ` / ` `) built from the ancestors.
|
|
92
|
+
*/
|
|
93
|
+
export function visibleRows(root, expanded, depth = 0, prefix = '', last = true, out = []) {
|
|
94
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
95
|
+
const node = root.children[i];
|
|
96
|
+
const isLast = i === root.children.length - 1;
|
|
97
|
+
out.push({ node, depth, last: isLast, prefix });
|
|
98
|
+
if (node.dir && expanded.has(node.rel)) {
|
|
99
|
+
// The gutter for a child: a vertical bar while this node still has siblings below
|
|
100
|
+
// it, spaces once it does not — that is what makes the lines reach the right parent.
|
|
101
|
+
const next = prefix + (isLast ? ' ' : '\u2502 ');
|
|
102
|
+
visibleRows(node, expanded, depth + 1, next, isLast, out);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every directory path in the tree, so the caller can "expand all" without a walk of its own. */
|
|
109
|
+
export function allDirs(root, out = []) {
|
|
110
|
+
for (const c of root.children) {
|
|
111
|
+
if (c.dir) { out.push(c.rel); allDirs(c, out); }
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Count the files under a node, for the `(N)` after a directory name. */
|
|
117
|
+
export function countFiles(node) {
|
|
118
|
+
if (!node.dir) return 1;
|
|
119
|
+
let n = 0;
|
|
120
|
+
for (const c of node.children) n += countFiles(c);
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render the browser.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} input
|
|
128
|
+
* @param {object} input.root from buildTree
|
|
129
|
+
* @param {Array} input.rows from visibleRows
|
|
130
|
+
* @param {number} input.sel selected index into `rows`
|
|
131
|
+
* @param {number} input.scroll first visible row
|
|
132
|
+
* @param {number} input.width
|
|
133
|
+
* @param {number} input.height rows available to the panel
|
|
134
|
+
* @param {string} [input.filter] substring the file list was narrowed by
|
|
135
|
+
* @param {string} [input.cwd]
|
|
136
|
+
* @returns {{ lines: string[], sel: number, scroll: number, hitRows: Array }}
|
|
137
|
+
*/
|
|
138
|
+
export function renderTree(input) {
|
|
139
|
+
const {
|
|
140
|
+
root, rows, width, height, cwd = '',
|
|
141
|
+
} = input;
|
|
142
|
+
const filter = String(input.filter || '');
|
|
143
|
+
// The caller's width is RESPECTED, not raised to a minimum: a row wider than the frame
|
|
144
|
+
// wraps and corrupts every row under it, so a floor here would be a bug, not a courtesy.
|
|
145
|
+
const w = Math.max(1, width | 0);
|
|
146
|
+
// Header (2) + footer (1) come out of the budget; a body of at least one row.
|
|
147
|
+
const bodyH = Math.max(1, (height | 0) - 3);
|
|
148
|
+
|
|
149
|
+
let sel = Math.max(0, Math.min((input.sel | 0), Math.max(0, rows.length - 1)));
|
|
150
|
+
let scroll = Math.max(0, input.scroll | 0);
|
|
151
|
+
if (sel < scroll) scroll = sel;
|
|
152
|
+
if (sel >= scroll + bodyH) scroll = sel - bodyH + 1;
|
|
153
|
+
|
|
154
|
+
const total = root.children.length ? countFiles(root) : 0;
|
|
155
|
+
const title = filter
|
|
156
|
+
? `Files matching "${filter}" — ${total} file(s)`
|
|
157
|
+
: `Files — ${total} file(s)`;
|
|
158
|
+
// Deliberately terse: the full phrasing ("↑↓ move · →/← open/close · Enter insert path ·
|
|
159
|
+
// Ctrl+O expand all · Esc close") is 71 columns and got clipped mid-word at 70.
|
|
160
|
+
const hint = '↑↓ move · →/← open · Enter insert · Ctrl+O all · Esc close';
|
|
161
|
+
|
|
162
|
+
const lines = [];
|
|
163
|
+
// The header and the hint are clamped to the panel like every other row. Unclamped, a
|
|
164
|
+
// narrow terminal pushed them past the width and the whole frame wrapped.
|
|
165
|
+
lines.push(col(truncate(' ' + title, w), C.white + C.bold));
|
|
166
|
+
lines.push(col(truncate(' ' + hint, w), C.gray));
|
|
167
|
+
|
|
168
|
+
const hitRows = [];
|
|
169
|
+
const shown = rows.slice(scroll, scroll + bodyH);
|
|
170
|
+
for (let i = 0; i < shown.length; i++) {
|
|
171
|
+
const { node, prefix } = shown[i];
|
|
172
|
+
const index = scroll + i;
|
|
173
|
+
const selected = index === sel;
|
|
174
|
+
const marker = node.dir ? (input.expandedSet && input.expandedSet.has(node.rel) ? '\u25be' : '\u25b8') : ' ';
|
|
175
|
+
const label = node.dir ? `${node.name}/` : node.name;
|
|
176
|
+
const lead = ` ${prefix}${marker} `;
|
|
177
|
+
const leadW = visWidth(lead);
|
|
178
|
+
// Budget for the name, AFTER the gutter and the marker. Measured in VISUAL columns,
|
|
179
|
+
// not characters: `slice` counts code units, so a name with a wide character or an
|
|
180
|
+
// escape in it would come out wider than asked and wrap the whole frame.
|
|
181
|
+
const suffixText = node.dir ? ` (${countFiles(node)})` : '';
|
|
182
|
+
const suffixW = visWidth(suffixText);
|
|
183
|
+
let room = Math.max(1, w - leadW - 1);
|
|
184
|
+
// The count is a NICER label than a longer name, so it is dropped only when keeping it
|
|
185
|
+
// would truncate the name to nothing.
|
|
186
|
+
const showSuffix = suffixW > 0 && room - suffixW >= 8;
|
|
187
|
+
if (showSuffix) room -= suffixW;
|
|
188
|
+
const cut = clipToWidth(label, room);
|
|
189
|
+
const text = lead + col(cut, selected ? C.hover : (node.dir ? C.cyan : C.fg))
|
|
190
|
+
+ (showSuffix ? col(suffixText, C.gray) : '');
|
|
191
|
+
// A selected row is painted full-width so the highlight is a band, not a word.
|
|
192
|
+
lines.push(selected ? padRight(text, w) : text);
|
|
193
|
+
if (selected) {
|
|
194
|
+
// The caller needs the row index to map a click back to this node.
|
|
195
|
+
hitRows.push(index);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Pad the body out so the panel is a solid block rather than a ragged one.
|
|
199
|
+
// Pad the body out so the panel is a solid block rather than a ragged one.
|
|
200
|
+
while (lines.length < bodyH + 2) lines.push(' '.repeat(w));
|
|
201
|
+
|
|
202
|
+
const pos = rows.length ? `${sel + 1}/${rows.length}` : '0/0';
|
|
203
|
+
// A row is `{ node, depth, prefix }`, NOT the node itself — reading `.rel` off the row
|
|
204
|
+
// silently produced an empty path, so the footer showed only the position.
|
|
205
|
+
const cur = rows[sel];
|
|
206
|
+
const rel = cur && cur.node ? cur.node.rel : '';
|
|
207
|
+
const footer = ` ${pos} ${rel}`;
|
|
208
|
+
lines.push(col(truncate(footer, w), C.gray));
|
|
209
|
+
|
|
210
|
+
return { lines, sel, scroll, hitRows };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function truncate(s, w) {
|
|
214
|
+
const str = String(s);
|
|
215
|
+
if (visWidth(str) <= w) return str;
|
|
216
|
+
return clipToWidth(str, w);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Clip a string to at most `w` VISUAL columns, ending with an ellipsis when it was cut.
|
|
221
|
+
*
|
|
222
|
+
* `slice` cannot do this: it counts UTF-16 code units, so a name with a wide character in
|
|
223
|
+
* it comes out wider than the budget and wraps the row it was supposed to fit.
|
|
224
|
+
*/
|
|
225
|
+
function clipToWidth(s, w) {
|
|
226
|
+
const str = String(s);
|
|
227
|
+
if (w <= 0) return '';
|
|
228
|
+
if (visWidth(str) <= w) return str;
|
|
229
|
+
let out = '';
|
|
230
|
+
let n = 0;
|
|
231
|
+
for (const ch of str) {
|
|
232
|
+
const cw = visWidth(ch);
|
|
233
|
+
if (n + cw > w - 1) break;
|
|
234
|
+
n += cw;
|
|
235
|
+
out += ch;
|
|
236
|
+
}
|
|
237
|
+
return out + ELLIPSIS;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function padRight(s, w) {
|
|
241
|
+
const have = visWidth(s);
|
|
242
|
+
return have >= w ? s : s + ' '.repeat(w - have);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The next selection when moving by `delta`, skipping nothing.
|
|
247
|
+
*
|
|
248
|
+
* Kept as a function rather than inline arithmetic so the wrap-around rule is stated once:
|
|
249
|
+
* the list WRAPS, because a browser that dead-ends at the bottom makes the arrow keys feel
|
|
250
|
+
* broken on a short list.
|
|
251
|
+
*/
|
|
252
|
+
export function moveSel(sel, delta, count) {
|
|
253
|
+
if (!count) return 0;
|
|
254
|
+
return ((sel + delta) % count + count) % count;
|
|
255
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Monotonic terminal-flush counter.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// "I changed the state" and "the terminal has that change" are different facts, and
|
|
6
|
+
// this TUI has code that conflates them.
|
|
7
|
+
//
|
|
8
|
+
// State changes reach the screen through `renderSoon()`, which only SCHEDULES a paint
|
|
9
|
+
// (`setImmediate`). Several places then read the screen's geometry back as if the paint
|
|
10
|
+
// had already happened: the mouse handlers map a click's row to a transcript line
|
|
11
|
+
// through `state._bodyScreenTop`, the transcript renderer anchors the scroll position to
|
|
12
|
+
// `state._anchorPin`, and a resize defers its repair to the next frame. Every one of
|
|
13
|
+
// those is computed by `composeFrame` — the same call the pending paint will make, with
|
|
14
|
+
// the same state, so the values agree. The fragile part is not the arithmetic, it is the
|
|
15
|
+
// ORDERING: any of them can run in the window between the state change and the paint,
|
|
16
|
+
// and if the paint is then superseded (a resize, a re-entrant render) the value that was
|
|
17
|
+
// acted upon belongs to a frame that never reached the terminal.
|
|
18
|
+
//
|
|
19
|
+
// A counter makes "a frame has been written" observable, which is what lets a caller
|
|
20
|
+
// wait for it instead of assuming it. Deliberately a plain module-level integer with no
|
|
21
|
+
// allocation and no subscriber list: it is read a handful of times per frame, not
|
|
22
|
+
// subscribed to.
|
|
23
|
+
|
|
24
|
+
let flushTick = 0;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record one completed terminal frame write. Called from the single place every screen
|
|
28
|
+
* change passes through, so "the tick advanced" means bytes left this process.
|
|
29
|
+
*
|
|
30
|
+
* Advanced even when a frame produced NO output (`diffFrame` returned an empty string
|
|
31
|
+
* when nothing changed): a frame that was correctly painted by writing nothing is just
|
|
32
|
+
* as painted, and gating on the tick would then wait for a change that will never come.
|
|
33
|
+
*/
|
|
34
|
+
export function noteTerminalFlush() {
|
|
35
|
+
flushTick++;
|
|
36
|
+
return flushTick;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The current tick. Monotonic for the life of the process; never resets. */
|
|
40
|
+
export function getTerminalFlushTick() {
|
|
41
|
+
return flushTick;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True once a frame has been written SINCE the given tick was taken.
|
|
46
|
+
*
|
|
47
|
+
* The usage that matters is "I am about to change state that is only observable after a
|
|
48
|
+
* paint; if a paint already happened since I looked, the value I would read is current,
|
|
49
|
+
* and if not, it is stale." Callers capture `getTerminalFlushTick()` when they widen a
|
|
50
|
+
* value (open a panel, start a drag) and use this to decide whether to re-read it.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} since a tick from `getTerminalFlushTick`.
|
|
53
|
+
*/
|
|
54
|
+
export function frameWrittenSince(since) {
|
|
55
|
+
return flushTick !== since;
|
|
56
|
+
}
|
package/src/fork.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A message that belongs to ONE conversation.
|
|
3
|
+
*
|
|
4
|
+
* `/fork` used `messages.slice()`, which copies the array but not the messages in it, so
|
|
5
|
+
* the fork and its parent shared every message object. Nothing went wrong until the
|
|
6
|
+
* fork edited one — then the parent changed too, in a session the user had already
|
|
7
|
+
* walked away from. A shallow copy of the message is the minimum; the nested fields are
|
|
8
|
+
* copied as well because a turn writes into them (`toolCalls`, a growing `content`
|
|
9
|
+
* string on a streamed assistant message, the `allLines` of a plugin log).
|
|
10
|
+
*
|
|
11
|
+
* The underscore-prefixed bookkeeping fields are NOT copied: they describe the parent’s
|
|
12
|
+
* render state, and carrying them over would make the fork display rows the new
|
|
13
|
+
* conversation has not produced.
|
|
14
|
+
*/
|
|
15
|
+
export function cloneMessageForFork(m) {
|
|
16
|
+
if (!m || typeof m !== 'object') return m;
|
|
17
|
+
const copy = {};
|
|
18
|
+
for (const k of Object.keys(m)) {
|
|
19
|
+
// Render bookkeeping: the fork has its own screen and its own scroll.
|
|
20
|
+
if (k === '_conv' || k === '_mdCache' || k === '_wrapped' || k === '_prevWrap') continue;
|
|
21
|
+
const v = m[k];
|
|
22
|
+
if (Array.isArray(v)) copy[k] = v.map((e) => (e && typeof e === 'object' ? { ...e } : e));
|
|
23
|
+
else if (v && typeof v === 'object') copy[k] = { ...v };
|
|
24
|
+
else copy[k] = v;
|
|
25
|
+
}
|
|
26
|
+
return copy;
|
|
27
|
+
}
|
|
28
|
+
// The command that uses it: `/fork`. Kept beside the clone so the two cannot drift.
|
|
29
|
+
//
|
|
30
|
+
export function forkOf(session, newId, now = Date.now()) {
|
|
31
|
+
const forked = {
|
|
32
|
+
...session,
|
|
33
|
+
id: newId(),
|
|
34
|
+
title: `${session.title || 'untitled'} (fork)`,
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
messages: (session.messages || []).map(cloneMessageForFork),
|
|
38
|
+
forkedFrom: session.forkedFrom || session.id || null,
|
|
39
|
+
};
|
|
40
|
+
// The turn counters belong to the conversation, not to the copy.
|
|
41
|
+
delete forked.rounds;
|
|
42
|
+
delete forked.steps;
|
|
43
|
+
return forked;
|
|
44
|
+
}
|
package/src/fx.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Currency conversion for the cost readout, so /cost can report in CNY as well
|
|
2
|
+
// as USD. Prices from models.dev are USD per 1M tokens; a Chinese user wants to
|
|
3
|
+
// see what that came to in ¥, and doing that by hand every time is the reason
|
|
4
|
+
// nobody looks at the number.
|
|
5
|
+
//
|
|
6
|
+
// Three ways to get the rate, in order:
|
|
7
|
+
// 1. an explicit override — `cny_per_usd` in config.toml, or HNCODE_CNY_PER_USD
|
|
8
|
+
// in the environment. Checked first and used with NO network call, so a
|
|
9
|
+
// machine without internet still reports a correct ¥ figure.
|
|
10
|
+
// 2. a live fetch from a keyless reference-rate API, memoized for HOURS: the
|
|
11
|
+
// rate moves a fraction of a percent a day, and this only feeds a readout.
|
|
12
|
+
// 3. nothing. The caller then says the rate is unavailable rather than
|
|
13
|
+
// converting with a guessed 7.0, which would be wrong in a way the user
|
|
14
|
+
// cannot see.
|
|
15
|
+
//
|
|
16
|
+
// Endpoints are tried in order because neither is guaranteed: frankfurter
|
|
17
|
+
// republishes ECB reference rates (stable, but ECB-only currencies), and
|
|
18
|
+
// open.er-api.com covers more currencies without a key.
|
|
19
|
+
|
|
20
|
+
const TTL_MS = 6 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
// currency -> { rate, source, at }. A plain Map, so a test can inspect or clear it.
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
|
|
25
|
+
/** Currencies /cost accepts. USD is the native one (models.dev prices in it). */
|
|
26
|
+
export const SUPPORTED_CURRENCIES = ['USD', 'CNY'];
|
|
27
|
+
|
|
28
|
+
/** `'cny'`/`'¥'` -> `'CNY'`; returns '' when unrecognised. */
|
|
29
|
+
export function normalizeCurrency(value) {
|
|
30
|
+
const s = String(value == null ? '' : value).trim().toUpperCase();
|
|
31
|
+
if (!s) return '';
|
|
32
|
+
if (s === '¥' || s === '¥' || s === 'RMB' || s === 'YUAN') return 'CNY';
|
|
33
|
+
if (s === '$' || s === 'USD' || s === 'US$') return 'USD';
|
|
34
|
+
return SUPPORTED_CURRENCIES.includes(s) ? s : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pull the USD->`currency` rate out of either API's response shape:
|
|
39
|
+
* frankfurter { amount: 1, base: 'USD', rates: { CNY: 6.70 } }
|
|
40
|
+
* open-er-api { result: 'success', base_code: 'USD', conversion_rates: { CNY: 7.12 } }
|
|
41
|
+
* Returns a positive number, or null when the payload has no usable rate.
|
|
42
|
+
*/
|
|
43
|
+
export function parseRate(json, currency = 'CNY') {
|
|
44
|
+
if (!json || typeof json !== 'object') return null;
|
|
45
|
+
const key = normalizeCurrency(currency) || 'CNY';
|
|
46
|
+
const table = json.rates || json.conversion_rates;
|
|
47
|
+
if (!table || typeof table !== 'object') return null;
|
|
48
|
+
const raw = table[key];
|
|
49
|
+
const n = Number(raw);
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** USD -> `currency` at `rate`. Null when either input is unusable. */
|
|
54
|
+
export function convert(usd, currency, rate) {
|
|
55
|
+
const amount = Number(usd);
|
|
56
|
+
if (!Number.isFinite(amount)) return null;
|
|
57
|
+
const cur = normalizeCurrency(currency) || 'USD';
|
|
58
|
+
if (cur === 'USD') return amount; // no rate needed
|
|
59
|
+
const r = Number(rate);
|
|
60
|
+
if (!Number.isFinite(r) || r <= 0) return null;
|
|
61
|
+
return amount * r;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The `<CUR>_PER_USD` env override, or null. Both the short and prefixed names. */
|
|
65
|
+
export function envRate(currency) {
|
|
66
|
+
const cur = normalizeCurrency(currency);
|
|
67
|
+
if (!cur || cur === 'USD') return null;
|
|
68
|
+
for (const key of [`HNCODE_${cur}_PER_USD`, `HNCODE_${cur}_RATE`]) {
|
|
69
|
+
const n = Number(process.env[key]);
|
|
70
|
+
if (Number.isFinite(n) && n > 0) return { rate: n, source: key };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ENDPOINTS = [
|
|
76
|
+
{ source: 'frankfurter (ECB)', url: (cur) => `https://api.frankfurter.app/latest?from=USD&to=${cur}` },
|
|
77
|
+
{ source: 'open.er-api.com', url: () => 'https://open.er-api.com/v6/latest/USD' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* USD -> `currency` rate.
|
|
82
|
+
*
|
|
83
|
+
* `opts.override` is a user-set rate (config.toml); it wins over the network.
|
|
84
|
+
* `opts.timeoutMs` bounds each request. Returns
|
|
85
|
+
* `{ rate, source, at }`, or null when nothing could supply one.
|
|
86
|
+
*/
|
|
87
|
+
export async function usdRate(currency, opts = {}) {
|
|
88
|
+
const cur = normalizeCurrency(currency);
|
|
89
|
+
if (!cur || cur === 'USD') return { rate: 1, source: 'native', at: Date.now() };
|
|
90
|
+
|
|
91
|
+
const override = Number(opts.override);
|
|
92
|
+
if (Number.isFinite(override) && override > 0) return { rate: override, source: 'config', at: Date.now() };
|
|
93
|
+
const fromEnv = envRate(cur);
|
|
94
|
+
if (fromEnv) return { ...fromEnv, at: Date.now() };
|
|
95
|
+
|
|
96
|
+
const hit = cache.get(cur);
|
|
97
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit;
|
|
98
|
+
|
|
99
|
+
for (const ep of ENDPOINTS) {
|
|
100
|
+
try {
|
|
101
|
+
const ctrl = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs || 8000);
|
|
103
|
+
let json;
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(ep.url(cur), { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
|
106
|
+
if (!res.ok) continue;
|
|
107
|
+
json = await res.json();
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
const rate = parseRate(json, cur);
|
|
112
|
+
if (rate) {
|
|
113
|
+
const entry = { rate, source: ep.source, at: Date.now() };
|
|
114
|
+
cache.set(cur, entry);
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
} catch { /* offline or blocked: try the next endpoint */ }
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The rate WITHOUT touching the network: an explicit override, the environment,
|
|
124
|
+
* or a still-fresh memoized value. Returns null when the answer would require a
|
|
125
|
+
* request.
|
|
126
|
+
*
|
|
127
|
+
* This exists because the status row is drawn from a SYNCHRONOUS render function,
|
|
128
|
+
* which cannot await a fetch. The rate therefore has to be warmed ahead of time
|
|
129
|
+
* (see the TUI's startup and /cost) and read here; a missing rate means the row
|
|
130
|
+
* falls back to USD rather than blocking or showing a converted guess.
|
|
131
|
+
*/
|
|
132
|
+
export function syncRate(currency, opts = {}) {
|
|
133
|
+
const cur = normalizeCurrency(currency);
|
|
134
|
+
if (!cur) return null;
|
|
135
|
+
if (cur === 'USD') return { rate: 1, source: 'native', at: Date.now() };
|
|
136
|
+
const override = Number(opts.override);
|
|
137
|
+
if (Number.isFinite(override) && override > 0) return { rate: override, source: 'config', at: Date.now() };
|
|
138
|
+
const fromEnv = envRate(cur);
|
|
139
|
+
if (fromEnv) return { ...fromEnv, at: Date.now() };
|
|
140
|
+
const hit = cache.get(cur);
|
|
141
|
+
return (hit && Date.now() - hit.at < TTL_MS) ? hit : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
/** Drop the memoized rates (tests, and a manual refresh). */
|
|
146
|
+
export function clearRateCache() { cache.clear(); }
|
|
147
|
+
|
|
148
|
+
export default { usdRate, syncRate, convert, parseRate, normalizeCurrency, envRate, clearRateCache, SUPPORTED_CURRENCIES };
|