@hncode/hncode 0.3.4 → 0.3.6
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/LICENSE +131 -674
- package/README.md +5 -4
- package/README_CN.md +5 -4
- package/package.json +2 -2
- package/src/config.js +57 -19
- package/src/llm.js +7 -3
- package/src/plugin.js +10 -8
- package/src/term.js +84 -46
- package/src/tui.js +381 -155
package/src/tui.js
CHANGED
|
@@ -261,7 +261,7 @@ export const TIPS = [
|
|
|
261
261
|
'/yolo auto-approves anything inside the workspace — risky paths still ask',
|
|
262
262
|
'/auto never interrupts you; everything runs and is decided automatically',
|
|
263
263
|
'Shift+Arrow selects text in the composer; Backspace or Delete removes it',
|
|
264
|
-
'
|
|
264
|
+
'Shift+Enter inserts a newline without sending the message',
|
|
265
265
|
'Ctrl+T expands or collapses the todo panel',
|
|
266
266
|
'Ctrl+O expands or collapses tool output, thinking blocks and Edit diffs',
|
|
267
267
|
'Ctrl+B moves a long-running foreground Bash command to the background',
|
|
@@ -677,17 +677,25 @@ function wrapWords(str, width) {
|
|
|
677
677
|
width = Math.max(1, width | 0);
|
|
678
678
|
const out = [];
|
|
679
679
|
let line = '';
|
|
680
|
+
// Running width of `line`. It used to be recomputed with
|
|
681
|
+
// `visualCol(line + ' ' + w)` on EVERY word, which is O(line) per word and so
|
|
682
|
+
// O(n²) per paragraph — a single long line (a streamed paragraph, a wide table
|
|
683
|
+
// row) cost ~46ms at 20KB and froze the UI on every markdown chunk. Widths are
|
|
684
|
+
// additive for the plain text this receives (no ANSI is present until
|
|
685
|
+
// inlineMarkdown runs later), so accumulate instead of re-measuring.
|
|
686
|
+
let lineW = 0;
|
|
680
687
|
// Expand tabs first: visualCol counts '\t' as 0 columns but the terminal
|
|
681
688
|
// advances to the next tab stop, which made the row wider than computed and
|
|
682
689
|
// wrapped it onto the following line.
|
|
683
690
|
const words = expandTabs(str).split(' ');
|
|
684
|
-
for (
|
|
691
|
+
for (const w of words) {
|
|
692
|
+
const wW = visualCol(w);
|
|
685
693
|
// A word longer than the line must be hard-split. This MUST measure visual
|
|
686
694
|
// columns (not string length): CJK characters occupy two cells, so slicing
|
|
687
695
|
// by `.length` would emit lines twice as wide as the box and the terminal
|
|
688
696
|
// would wrap them onto the next row, corrupting the layout.
|
|
689
|
-
if (
|
|
690
|
-
if (line) { out.push(line); line = ''; }
|
|
697
|
+
if (wW > width) {
|
|
698
|
+
if (line) { out.push(line); line = ''; lineW = 0; }
|
|
691
699
|
let chunk = '';
|
|
692
700
|
let cw = 0;
|
|
693
701
|
for (const ch of w) {
|
|
@@ -695,12 +703,18 @@ function wrapWords(str, width) {
|
|
|
695
703
|
if (cw + chw > width) { out.push(chunk); chunk = ''; cw = 0; }
|
|
696
704
|
chunk += ch; cw += chw;
|
|
697
705
|
}
|
|
698
|
-
if (chunk) line = chunk;
|
|
706
|
+
if (chunk) { line = chunk; lineW = cw; }
|
|
699
707
|
continue;
|
|
700
708
|
}
|
|
701
|
-
const
|
|
702
|
-
if (
|
|
703
|
-
|
|
709
|
+
const sep = line ? 1 : 0; // the single space we would add
|
|
710
|
+
if (lineW + sep + wW <= width) {
|
|
711
|
+
line = line ? line + ' ' + w : w;
|
|
712
|
+
lineW += sep + wW;
|
|
713
|
+
} else {
|
|
714
|
+
if (line) out.push(line);
|
|
715
|
+
line = w;
|
|
716
|
+
lineW = wW;
|
|
717
|
+
}
|
|
704
718
|
}
|
|
705
719
|
if (line) out.push(line);
|
|
706
720
|
else if (out.length === 0) out.push('');
|
|
@@ -2209,14 +2223,34 @@ export function argStreamLineCount(st, key) {
|
|
|
2209
2223
|
// streamed (assistant text and a Write's content only ever grow), and is written
|
|
2210
2224
|
// once when a turn completes. A large equal-length replacement does not occur on
|
|
2211
2225
|
// these fields — the diff/content that can change arbitrarily live elsewhere.
|
|
2226
|
+
// Fingerprint memo. `fp()` used to build `${len}:${text}` for every field of
|
|
2227
|
+
// every message on EVERY frame — for a 3.4k-message transcript the key strings
|
|
2228
|
+
// alone measured ~60ms per frame, which starved the 80ms animation tick and
|
|
2229
|
+
// made the startup animation visibly stall. Text fields change rarely, so the
|
|
2230
|
+
// fingerprint is memoised per string VALUE: the same immutable string returns
|
|
2231
|
+
// the same fingerprint without re-measuring. The map is bounded so a long
|
|
2232
|
+
// session with ever-growing streamed text cannot grow it without limit.
|
|
2212
2233
|
const FP_VERBATIM_MAX = 4096;
|
|
2234
|
+
const FP_CACHE_MAX = 8192;
|
|
2235
|
+
const fpCache = new Map();
|
|
2213
2236
|
function fp(s) {
|
|
2214
2237
|
if (s == null) return '0';
|
|
2215
|
-
const str = String(s);
|
|
2238
|
+
const str = typeof s === 'string' ? s : String(s);
|
|
2239
|
+
const hit = fpCache.get(str);
|
|
2240
|
+
if (hit !== undefined) return hit;
|
|
2216
2241
|
const n = str.length;
|
|
2217
|
-
|
|
2242
|
+
// Short strings keep the text inline (cheap and collision-free); long ones use
|
|
2243
|
+
// the length alone, as before.
|
|
2244
|
+
const out = n <= FP_VERBATIM_MAX ? n + ':' + str : n + ':';
|
|
2245
|
+
if (fpCache.size >= FP_CACHE_MAX) {
|
|
2246
|
+
const oldest = fpCache.keys().next().value;
|
|
2247
|
+
if (oldest !== undefined) fpCache.delete(oldest);
|
|
2248
|
+
}
|
|
2249
|
+
fpCache.set(str, out);
|
|
2250
|
+
return out;
|
|
2218
2251
|
}
|
|
2219
2252
|
|
|
2253
|
+
|
|
2220
2254
|
// Fingerprint of a tool-args object for the render-cache key. `JSON.stringify`
|
|
2221
2255
|
// was used here and stringified EVERY argument — including a streaming Write's
|
|
2222
2256
|
// whole file content — on every frame. The rendered tool line only shows the key
|
|
@@ -2234,82 +2268,158 @@ function fpArgs(args) {
|
|
|
2234
2268
|
return out;
|
|
2235
2269
|
}
|
|
2236
2270
|
|
|
2237
|
-
function renderChatLines(state, w) {
|
|
2271
|
+
export function renderChatLines(state, w, viewport) {
|
|
2238
2272
|
const out = [];
|
|
2239
2273
|
const expanded = !!state.expanded;
|
|
2240
|
-
// One blank column on the left of the whole transcript, so the markers (❯, ●,
|
|
2241
|
-
// ↳) do not sit flush against the screen edge.
|
|
2242
2274
|
const PAD = ' ';
|
|
2243
2275
|
const padW = visualCol(PAD);
|
|
2244
2276
|
const innerW = Math.max(1, w - padW);
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2277
|
+
const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2278
|
+
const chat = state.chat;
|
|
2279
|
+
const n = chat.length;
|
|
2280
|
+
|
|
2281
|
+
// Pass 1: exact row count per message (and the total). Every message's cache
|
|
2282
|
+
// is validated/rebuilt here so the counts are exact; only the STRING assembly
|
|
2283
|
+
// is limited to the visible window.
|
|
2284
|
+
const rowStart = new Int32Array(n);
|
|
2285
|
+
const msgLen = new Int32Array(n);
|
|
2286
|
+
let cursor = 0;
|
|
2287
|
+
for (let mi = 0; mi < n; mi++) {
|
|
2288
|
+
const m = chat[mi];
|
|
2289
|
+
const border = m.role === 'user' || m.role === 'queued' || m.role === 'bash';
|
|
2290
|
+
const ww = border ? Math.max(1, Math.min(1, innerW - 4)) : innerW; // placeholder
|
|
2291
|
+
void ww;
|
|
2292
|
+
rowStart[mi] = cursor;
|
|
2293
|
+
// Reuse/minimal layout for the row count: same width/border arithmetic as
|
|
2294
|
+
// the layout path below. We need boxText for bordered messages.
|
|
2295
|
+
const boxInner = Math.max(1, innerW - 4);
|
|
2296
|
+
const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
|
|
2297
|
+
const rowWidth = border ? boxText : innerW;
|
|
2298
|
+
const cached = m._cache;
|
|
2299
|
+
const valid = cached
|
|
2300
|
+
&& cached.rowW === rowWidth && cached.expanded === expanded
|
|
2301
|
+
&& cached.textRef === m.text && cached.streamRef === m.streamContent
|
|
2302
|
+
&& cached.liveRef === m.liveOutput && cached.failed === m.failed
|
|
2303
|
+
&& cached.diffRef === m.diff && cached.countsRef === m._diffCounts
|
|
2304
|
+
&& cached.keyArgs === (m.toolArgs ? fpArgs(m.toolArgs) : '0')
|
|
2305
|
+
&& cached.spinKey === (m.role === 'tool' && m.pending ? (state.spin || 0) : 0)
|
|
2306
|
+
&& cached.isPending === (m.pending ? 1 : 0);
|
|
2307
|
+
if (valid) {
|
|
2308
|
+
msgLen[mi] = cached.rows.length + (border ? 2 : 0);
|
|
2309
|
+
} else {
|
|
2310
|
+
const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart);
|
|
2311
|
+
const wrapped = rows.map(rowToLine);
|
|
2312
|
+
const spinKey2 = (m.role === 'tool' && m.pending) ? (state.spin || 0) : 0;
|
|
2313
|
+
const isPending2 = m.pending ? 1 : 0;
|
|
2314
|
+
const cc = {
|
|
2315
|
+
rowW: rowWidth, expanded, spinKey: spinKey2, isPending: isPending2,
|
|
2316
|
+
textRef: m.text, streamRef: m.streamContent, liveRef: m.liveOutput,
|
|
2317
|
+
failed: m.failed, diffRef: m.diff, countsRef: m._diffCounts,
|
|
2318
|
+
keyArgs: m.toolArgs ? fpArgs(m.toolArgs) : '0',
|
|
2319
|
+
rows: wrapped, spinRows: null,
|
|
2320
|
+
};
|
|
2321
|
+
for (let i = 0; i < wrapped.length; i++)
|
|
2322
|
+
if (wrapped[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
|
|
2323
|
+
if (!cc.spinRows) cc.spinRows = new Set();
|
|
2324
|
+
cc.spinRows.add(i);
|
|
2325
|
+
}
|
|
2326
|
+
m._cache = cc;
|
|
2327
|
+
msgLen[mi] = wrapped.length + (border ? 2 : 0);
|
|
2328
|
+
}
|
|
2329
|
+
cursor += msgLen[mi];
|
|
2330
|
+
}
|
|
2331
|
+
const total = cursor;
|
|
2332
|
+
state._chatTotal = total;
|
|
2333
|
+
|
|
2334
|
+
// Visible window: [total - bodyH - scroll, total - scroll), with a small
|
|
2335
|
+
// margin above so a message crossing the boundary renders whole. When no
|
|
2336
|
+
// viewport is given, render everything (full render).
|
|
2337
|
+
let visibleFrom = 0, visibleTo = -1;
|
|
2338
|
+
if (viewport && Number.isFinite(viewport.bodyH) && viewport.bodyH > 0) {
|
|
2339
|
+
const bodyH = viewport.bodyH;
|
|
2340
|
+
// Clamp scroll against the total we just computed, so a stale scroll value
|
|
2341
|
+
// cannot push the window past the end (missing the tail).
|
|
2342
|
+
const maxScroll = Math.max(0, total - bodyH);
|
|
2343
|
+
const scroll = Math.min(maxScroll, Math.max(0, viewport.scroll || 0));
|
|
2344
|
+
const end = total - scroll;
|
|
2345
|
+
const start = end - bodyH;
|
|
2346
|
+
visibleFrom = Math.max(0, start - 12);
|
|
2347
|
+
visibleTo = Math.min(total, end + 12);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Pass 2: assemble final strings, but only for messages that overlap the
|
|
2351
|
+
// visible range (or the whole transcript when no viewport).
|
|
2352
|
+
cursor = 0;
|
|
2353
|
+
for (let mi = 0; mi < n; mi++) {
|
|
2354
|
+
const msg = chat[mi];
|
|
2251
2355
|
const bordered = msg.role === 'user' || msg.role === 'queued' || msg.role === 'bash';
|
|
2252
|
-
// A bash-mode echo uses the violet shellMode frame — same hue as kimi.
|
|
2253
2356
|
const boxColor = msg.role === 'bash' ? C.shellMode : C.border;
|
|
2254
|
-
|
|
2255
|
-
// has a 1-column margin (matching the left margin from `PAD`).
|
|
2256
|
-
const boxW = innerW - 2; // total box width, including both │
|
|
2257
|
-
const boxInner = Math.max(1, boxW - 2); // span the two │s occupy
|
|
2258
|
-
// One column of padding inside each wall, mirroring the composer box.
|
|
2259
|
-
const boxPad = Math.min(1, Math.max(0, boxInner - 1));
|
|
2260
|
-
const boxText = Math.max(1, boxInner - boxPad * 2);
|
|
2261
|
-
const topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
|
|
2262
|
-
const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
|
|
2263
|
-
// A box always needs its TOP border — including when the message is the first
|
|
2264
|
-
// row of the transcript. (The earlier `out.length > 0` guard came from the
|
|
2265
|
-
// rule-only version, where a leading divider looked wrong; a box without a
|
|
2266
|
-
// top is simply broken.) For adjacent bordered messages the previous box's
|
|
2267
|
-
// bottom already serves as this one's top, so skip the duplicate.
|
|
2268
|
-
if (bordered && out[out.length - 1] !== topRule) out.push(topRule);
|
|
2269
|
-
// The cache key MUST include everything messageLines() reads. toolArgs was
|
|
2270
|
-
// missing, so a tool row rendered at `tool_start` (args still {}) kept its
|
|
2271
|
-
// stale "Using Bash" line even after `tool_use` delivered the command —
|
|
2272
|
-
// the row only appeared once some OTHER keyed field changed. liveOutput is
|
|
2273
|
-
// keyed because it grows while a command runs; `failed` flips the bullet
|
|
2274
|
-
// and the result colour red once the tool reports a non-zero exit.
|
|
2275
|
-
// The row WIDTH is keyed (not `w`) because the rows are laid out to it:
|
|
2276
|
-
// `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
|
|
2277
|
-
const rowW = bordered ? boxText : innerW;
|
|
2278
|
-
// The cache key MUST include everything messageLines() reads. `spin` is
|
|
2279
|
-
// keyed ONLY for a pending TOOL row: formatToolLine sweeps the tool name's
|
|
2280
|
-
// colour with it. The pending THINKING row deliberately does not key it —
|
|
2281
|
-
// its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
|
|
2282
|
-
// the 80ms tick no longer re-wraps the reasoning preview every frame.
|
|
2283
|
-
const spinKey = (msg.role === 'tool' && msg.pending) ? (state.spin || 0) : 0;
|
|
2284
|
-
const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${fp(msg.text)}\u0000${fp(msg.streamContent)}`
|
|
2285
|
-
+ `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${fpArgs(msg.toolArgs)}`
|
|
2286
|
-
+ `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
|
|
2287
|
-
let cached = msg._cache;
|
|
2288
|
-
if (!cached || cached.key !== key) {
|
|
2289
|
-
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin, state.pulseStart);
|
|
2290
|
-
cached = { key, rows: rows.map(rowToLine) };
|
|
2291
|
-
msg._cache = cached;
|
|
2292
|
-
}
|
|
2293
|
-
// Swap the live spinner frame into any row that carries the placeholder.
|
|
2294
|
-
// Done AFTER the cache, so the glyph animates without invalidating it.
|
|
2295
|
-
const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2296
|
-
const live = (r) => (r.indexOf(SPIN_PLACEHOLDER) >= 0 ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
|
|
2297
|
-
// The margin is added here (outside the cache) so it is not baked into the
|
|
2298
|
-
// cached rows. A bordered message also gets its side walls, laid out to the
|
|
2299
|
-
// box's inner width so it lines up with the composer exactly.
|
|
2357
|
+
let topRule = null, botRule = null, boxPad = 1, boxText = innerW;
|
|
2300
2358
|
if (bordered) {
|
|
2301
|
-
const
|
|
2302
|
-
|
|
2303
|
-
|
|
2359
|
+
const boxW = innerW - 2;
|
|
2360
|
+
const boxInner = Math.max(1, boxW - 2);
|
|
2361
|
+
boxPad = Math.min(1, Math.max(0, boxInner - 1));
|
|
2362
|
+
boxText = Math.max(1, boxInner - boxPad * 2);
|
|
2363
|
+
topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
|
|
2364
|
+
botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
|
|
2365
|
+
}
|
|
2366
|
+
const cc = msg._cache || { rows: [] };
|
|
2367
|
+
// Shared wall with the previous bordered message (its bottom rule is ours).
|
|
2368
|
+
let shared = false;
|
|
2369
|
+
if (bordered && mi > 0) {
|
|
2370
|
+
const prev = chat[mi - 1];
|
|
2371
|
+
shared = (prev.role === 'user' || prev.role === 'queued' || prev.role === 'bash')
|
|
2372
|
+
&& rowStart[mi - 1] + msgLen[mi - 1] === cursor;
|
|
2373
|
+
}
|
|
2374
|
+
const len = msgLen[mi];
|
|
2375
|
+
const visible = visibleTo < 0 || (cursor + len > visibleFrom && cursor < visibleTo);
|
|
2376
|
+
if (visible) {
|
|
2377
|
+
const spinRows = cc.spinRows;
|
|
2378
|
+
const liveAt = (r, i) => (spinRows && spinRows.has(i) ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
|
|
2379
|
+
if (bordered && !shared) out.push(topRule);
|
|
2380
|
+
if (bordered) {
|
|
2381
|
+
const pad = ' '.repeat(boxPad);
|
|
2382
|
+
const wall = col('│', boxColor);
|
|
2383
|
+
for (let i = 0; i < cc.rows.length; i++)
|
|
2384
|
+
out.push(PAD + wall + pad + fitAnsi(liveAt(cc.rows[i], i), boxText) + pad + wall);
|
|
2385
|
+
} else {
|
|
2386
|
+
for (let i = 0; i < cc.rows.length; i++) out.push(PAD + liveAt(cc.rows[i], i));
|
|
2304
2387
|
}
|
|
2388
|
+
if (bordered) out.push(botRule);
|
|
2305
2389
|
} else {
|
|
2306
|
-
for (
|
|
2390
|
+
for (let i = 0; i < len; i++) out.push('');
|
|
2307
2391
|
}
|
|
2308
|
-
|
|
2392
|
+
cursor += len;
|
|
2309
2393
|
}
|
|
2310
2394
|
return out;
|
|
2311
2395
|
}
|
|
2312
2396
|
|
|
2397
|
+
// ---- scroll anchoring --------------------------------------------------------
|
|
2398
|
+
// Keep a scrolled-up viewport pinned to the SAME content while new rows arrive.
|
|
2399
|
+
//
|
|
2400
|
+
// `state.scroll` counts rows up from the bottom, so when the transcript grows by
|
|
2401
|
+
// N rows the offset must grow by N as well — otherwise the new rows push the
|
|
2402
|
+
// content the user is reading off the top of the screen (the "it scrolls on its
|
|
2403
|
+
// own while the agent is talking" bug: measured as the viewed line moving up one
|
|
2404
|
+
// row per streamed chunk).
|
|
2405
|
+
//
|
|
2406
|
+
// The subtlety is the BASELINE. The previous version returned early while the
|
|
2407
|
+
// view was pinned (`scroll === 0`) and only recorded `_anchorRows` otherwise, so
|
|
2408
|
+
// during a turn the baseline went stale. The first append after the user
|
|
2409
|
+
// scrolled up then found `prev == null` (or an ancient value), skipped the
|
|
2410
|
+
// compensation, and the view drifted. Recording the baseline on EVERY call —
|
|
2411
|
+
// pinned or not — makes `rows - prev` always "rows added since the last paint".
|
|
2412
|
+
//
|
|
2413
|
+
// Pure: takes the state and the current chat row count, returns the new `scroll`.
|
|
2414
|
+
export function anchorScrollNext(state, rows) {
|
|
2415
|
+
const prev = state._anchorRows;
|
|
2416
|
+
state._anchorRows = rows;
|
|
2417
|
+
if ((state.scroll || 0) === 0) return 0; // pinned: keep following the tail
|
|
2418
|
+
if (prev != null && rows > prev) return (state.scroll || 0) + (rows - prev);
|
|
2419
|
+
return state.scroll || 0;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
|
|
2313
2423
|
// Serialise a Line (array of spans) to an ANSI string.
|
|
2314
2424
|
function lineToString(line) {
|
|
2315
2425
|
let s = '';
|
|
@@ -3050,8 +3160,15 @@ export function composeFrame(state, cols, rows) {
|
|
|
3050
3160
|
// this too and appended a whole extra viewport of rows ON TOP of the editor.
|
|
3051
3161
|
// The frame then overflowed, the top trim ate the editor's own title and text,
|
|
3052
3162
|
// and /personal & /set-system-prompt showed nothing but a stray caret.
|
|
3053
|
-
|
|
3054
|
-
|
|
3163
|
+
if (!state.editor && !state.panel && !state.form && !state.picker) {
|
|
3164
|
+
// Windowed: renderChatLines computes the visible tail from bodyH+scroll and
|
|
3165
|
+
// fills the rest with padding. `chat` still has the full row count (holes
|
|
3166
|
+
// are blank strings), so `chat.length` remains the true total and the
|
|
3167
|
+
// slices below stay correct. `state._forceFullRender` (tests) bypasses the
|
|
3168
|
+
// window so the full path can be compared.
|
|
3169
|
+
const chat = state._forceFullRender
|
|
3170
|
+
? renderChatLines(state, w)
|
|
3171
|
+
: renderChatLines(state, w, { bodyH, scroll: state.scroll || 0 });
|
|
3055
3172
|
if (state.selection && state.selection.anchor && state.selection.head) {
|
|
3056
3173
|
// Apply selection highlight directly on ANSI strings
|
|
3057
3174
|
for (let i = 0; i < chat.length; i++) {
|
|
@@ -3691,6 +3808,46 @@ function fieldCaretCol(field, active) {
|
|
|
3691
3808
|
const c = Math.max(0, Math.min(shown.length, field.caret || 0));
|
|
3692
3809
|
return visualCol(shown.slice(0, c));
|
|
3693
3810
|
}
|
|
3811
|
+
// ---- prompt history (persisted across restarts) -----------------------------
|
|
3812
|
+
// Kept in the hncode config directory so the history survives a restart; the
|
|
3813
|
+
// in-memory array alone meant every relaunch started with an empty ↑ list.
|
|
3814
|
+
const HISTORY_MAX = 500;
|
|
3815
|
+
|
|
3816
|
+
function historyFile() {
|
|
3817
|
+
try {
|
|
3818
|
+
return path.join(path.dirname(hncodeConfigFile()), 'history.json');
|
|
3819
|
+
} catch {
|
|
3820
|
+
return path.join(os.homedir(), '.hncode', 'history.json');
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
export function loadHistory() {
|
|
3825
|
+
try {
|
|
3826
|
+
const raw = fs.readFileSync(historyFile(), 'utf8');
|
|
3827
|
+
const arr = JSON.parse(raw);
|
|
3828
|
+
if (!Array.isArray(arr)) return [];
|
|
3829
|
+
return arr.filter((x) => typeof x === 'string' && x.trim() !== '').slice(-HISTORY_MAX);
|
|
3830
|
+
} catch {
|
|
3831
|
+
return [];
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
export function saveHistory(list) {
|
|
3836
|
+
try {
|
|
3837
|
+
const file = historyFile();
|
|
3838
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3839
|
+
// Newest last, capped, and de-duplicated while keeping order.
|
|
3840
|
+
const seen = new Set();
|
|
3841
|
+
const out = [];
|
|
3842
|
+
for (const x of (list || []).slice(-HISTORY_MAX)) {
|
|
3843
|
+
if (typeof x !== 'string' || !x.trim()) continue;
|
|
3844
|
+
if (seen.has(x)) continue;
|
|
3845
|
+
seen.add(x);
|
|
3846
|
+
out.push(x);
|
|
3847
|
+
}
|
|
3848
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 0), 'utf8');
|
|
3849
|
+
} catch { /* history is a convenience; never break the session over it */ }
|
|
3850
|
+
}
|
|
3694
3851
|
|
|
3695
3852
|
// ---- state factory ----
|
|
3696
3853
|
export function makeState({ cfg, session, opts }) {
|
|
@@ -3821,7 +3978,7 @@ export function makeState({ cfg, session, opts }) {
|
|
|
3821
3978
|
notice: '',
|
|
3822
3979
|
noticeKind: 'info',
|
|
3823
3980
|
confirmExit: false,
|
|
3824
|
-
history:
|
|
3981
|
+
history: loadHistory(), // restored from disk so ↑ works across restarts
|
|
3825
3982
|
historyIdx: -1,
|
|
3826
3983
|
_tipIndex: 0,
|
|
3827
3984
|
};
|
|
@@ -3852,6 +4009,9 @@ export function reconstructChat(s) {
|
|
|
3852
4009
|
const shellByAnchor = new Map();
|
|
3853
4010
|
const shells = (s && s.shellHistory) || [];
|
|
3854
4011
|
for (const sh of shells) {
|
|
4012
|
+
// A malformed entry must not take the whole resume down: `sh.anchor` on a
|
|
4013
|
+
// null threw right here, before the guard inside emitShells() could help.
|
|
4014
|
+
if (!sh || typeof sh !== 'object') continue;
|
|
3855
4015
|
const a = Number(sh.anchor != null ? sh.anchor : 0);
|
|
3856
4016
|
if (!shellByAnchor.has(a)) shellByAnchor.set(a, []);
|
|
3857
4017
|
shellByAnchor.get(a).push(sh);
|
|
@@ -3859,8 +4019,11 @@ export function reconstructChat(s) {
|
|
|
3859
4019
|
// Emit the shell block for anchor `a` (command + its output) onto `out`.
|
|
3860
4020
|
const emitShells = (out, a) => {
|
|
3861
4021
|
for (const sh of shellByAnchor.get(a) || []) {
|
|
4022
|
+
// Same junk-entry guard as the messages loop: a malformed shellHistory
|
|
4023
|
+
// entry must not take the whole resume down with it.
|
|
4024
|
+
if (!sh || typeof sh !== 'object') continue;
|
|
3862
4025
|
out.push({ role: 'bash', text: String(sh.cmd || '') });
|
|
3863
|
-
if (sh.result) out.push({ role: 'tool_result', text: sh.result, failed: sh.ok === false });
|
|
4026
|
+
if (sh.result) out.push({ role: 'tool_result', text: String(sh.result), failed: sh.ok === false });
|
|
3864
4027
|
}
|
|
3865
4028
|
};
|
|
3866
4029
|
|
|
@@ -3868,6 +4031,11 @@ export function reconstructChat(s) {
|
|
|
3868
4031
|
const msgs = (s && s.messages) || [];
|
|
3869
4032
|
for (let mi = 0; mi < msgs.length; mi++) {
|
|
3870
4033
|
const m = msgs[mi];
|
|
4034
|
+
// Skip junk entries. A session file can carry a null (older writer, hand
|
|
4035
|
+
// edit, truncated write); reading `m.role` off it threw and the whole resume
|
|
4036
|
+
// died with an unhandled TypeError instead of showing the rest of the
|
|
4037
|
+
// conversation.
|
|
4038
|
+
if (!m || typeof m !== 'object') continue;
|
|
3871
4039
|
const role = m.role;
|
|
3872
4040
|
const text = typeof m.content === 'string' ? m.content : '';
|
|
3873
4041
|
// Inject shell commands that anchored BEFORE this message (in the slot after
|
|
@@ -3876,7 +4044,8 @@ export function reconstructChat(s) {
|
|
|
3876
4044
|
if (role === 'user') out.push({ role: 'user', text });
|
|
3877
4045
|
else if (role === 'assistant') {
|
|
3878
4046
|
out.push({ role: 'assistant', text });
|
|
3879
|
-
for (const tc of (m.toolCalls
|
|
4047
|
+
for (const tc of (Array.isArray(m.toolCalls) ? m.toolCalls : [])) {
|
|
4048
|
+
if (!tc || typeof tc !== 'object') continue;
|
|
3880
4049
|
out.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
|
|
3881
4050
|
}
|
|
3882
4051
|
} else if (role === 'tool') {
|
|
@@ -4845,7 +5014,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
4845
5014
|
'',
|
|
4846
5015
|
'Shortcuts',
|
|
4847
5016
|
' Enter send the message',
|
|
4848
|
-
'
|
|
5017
|
+
' Shift+Enter insert a newline',
|
|
4849
5018
|
' Ctrl+Shift+C copy the selection (or the last answer)',
|
|
4850
5019
|
' Ctrl+Shift+V paste the clipboard (multi-line pastes collapse)',
|
|
4851
5020
|
' ↑ / ↓ input history (empty composer) · scroll the chat',
|
|
@@ -5194,7 +5363,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
5194
5363
|
return;
|
|
5195
5364
|
}
|
|
5196
5365
|
case 'plugins': {
|
|
5197
|
-
const { pluginCommands,
|
|
5366
|
+
const { pluginCommands, API } = await import('./plugin.js');
|
|
5367
|
+
// `loadedPlugins` is module-private; the public way to read it is the
|
|
5368
|
+
// API.plugins getter. Importing a non-exported name yielded `undefined`
|
|
5369
|
+
// and crashed on `.length`.
|
|
5370
|
+
const loadedPlugins = API.plugins;
|
|
5198
5371
|
if (!loadedPlugins.length) { app('No plugins loaded. Check ~/.hncode/plugins/'); return; }
|
|
5199
5372
|
const lines = ['Loaded plugins:', ...loadedPlugins.map((p) => ` • ${p.name} v${p.version} (${p.id})`)];
|
|
5200
5373
|
if (pluginCommands.length) {
|
|
@@ -5578,13 +5751,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
5578
5751
|
// Check if this is a plugin-registered command.
|
|
5579
5752
|
if (isPluginCommand(cmd)) {
|
|
5580
5753
|
const pc = findCommand(cmd);
|
|
5581
|
-
|
|
5582
|
-
|
|
5754
|
+
// NOTE: `pc` comes from the plugin registry, which does NOT carry the
|
|
5755
|
+
// `_plugin` marker — that is only added by allCommands() for display. The
|
|
5756
|
+
// old guard `pc._plugin` was therefore always falsy, so plugin commands
|
|
5757
|
+
// fell through to "Unknown command" and never ran at all. isPluginCommand
|
|
5758
|
+
// above already established that this name resolves to a plugin.
|
|
5759
|
+
if (pc && typeof pc.run === 'function') {
|
|
5760
|
+
// AWAIT the handler: plugins may register an async `run`, and without
|
|
5761
|
+
// awaiting, a rejection escaped as an unhandled promise rejection and
|
|
5762
|
+
// the catch below never saw it (the command appeared to fail silently).
|
|
5763
|
+
try { await pc.run(raw, { state, cfg, session, h, app, appErr }); }
|
|
5583
5764
|
catch (e) { appErr(`Plugin command "/${cmd}" failed: ${e.message}`); }
|
|
5584
5765
|
return;
|
|
5585
5766
|
}
|
|
5586
5767
|
}
|
|
5587
5768
|
// Unknown command: show error message
|
|
5769
|
+
// Unknown command: show error message
|
|
5588
5770
|
appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
|
|
5589
5771
|
return;
|
|
5590
5772
|
}
|
|
@@ -5924,30 +6106,12 @@ export async function startTUI(opts) {
|
|
|
5924
6106
|
const stdout = process.stdout;
|
|
5925
6107
|
const addChat = (msg) => {
|
|
5926
6108
|
state.chat.push(normalizeMsg(msg));
|
|
5927
|
-
//
|
|
5928
|
-
// scroll
|
|
5929
|
-
//
|
|
5930
|
-
//
|
|
5931
|
-
//
|
|
5932
|
-
|
|
5933
|
-
// so appending a tall message (a long Read/Bash result) pushed the whole
|
|
5934
|
-
// transcript up by its height in a single frame instead of scrolling by one
|
|
5935
|
-
// row — the "everything jumps" report. When the user IS scrolled up we leave
|
|
5936
|
-
// the offset alone, which is the other half of the original intent.
|
|
5937
|
-
if ((state.scroll || 0) === 0) {
|
|
5938
|
-
state.scroll = 0; // pinned: keep following the newest message
|
|
5939
|
-
} else {
|
|
5940
|
-
// Scrolled up: grow the offset by exactly the number of rows this message
|
|
5941
|
-
// added, so the content the user is reading does not move.
|
|
5942
|
-
try {
|
|
5943
|
-
const rows = renderChatLines(state, dims().cols).length;
|
|
5944
|
-
if (state._addChatRows != null) {
|
|
5945
|
-
const grew = Math.max(0, rows - state._addChatRows);
|
|
5946
|
-
if (grew) state.scroll = (state.scroll || 0) + grew;
|
|
5947
|
-
}
|
|
5948
|
-
state._addChatRows = rows;
|
|
5949
|
-
} catch { /* rendering is best-effort here; never break a message append */ }
|
|
5950
|
-
}
|
|
6109
|
+
// Auto-follow / re-anchor is delegated to anchorScroll() so exactly ONE place
|
|
6110
|
+
// adjusts `scroll`. This block used to run its own row-delta compensation
|
|
6111
|
+
// against a second baseline (_addChatRows) which — like anchorScroll's old
|
|
6112
|
+
// one — was not refreshed while the view was pinned. After the user scrolled
|
|
6113
|
+
// up, BOTH paths added the same rows, so the view jumped by twice the growth.
|
|
6114
|
+
anchorScroll();
|
|
5951
6115
|
renderFrame();
|
|
5952
6116
|
};
|
|
5953
6117
|
let noticeTimer = null;
|
|
@@ -7301,12 +7465,26 @@ export async function startTUI(opts) {
|
|
|
7301
7465
|
renderFrame(); return;
|
|
7302
7466
|
}
|
|
7303
7467
|
if (t.key === 'up' || t.key === 'down') {
|
|
7304
|
-
// ↑/↓
|
|
7305
|
-
//
|
|
7306
|
-
//
|
|
7307
|
-
//
|
|
7308
|
-
|
|
7309
|
-
|
|
7468
|
+
// ↑/↓ while the agent streams: FIRST give the composer its caret move
|
|
7469
|
+
// (a multi-line draft must still navigate), then fall back to scrolling
|
|
7470
|
+
// ONLY when there is nothing else to do. The scroll is what made ↑ feel
|
|
7471
|
+
// stuck while output streamed, so it is now the last resort.
|
|
7472
|
+
const cur = state.input || '';
|
|
7473
|
+
if (cur === '') {
|
|
7474
|
+
// Empty composer: the key belongs to HISTORY, not to the viewport.
|
|
7475
|
+
// With no history the key does nothing at all — silently scrolling
|
|
7476
|
+
// the transcript is what made a bare ↑ feel like it ate the key.
|
|
7477
|
+
if (state.history.length) {
|
|
7478
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7479
|
+
state.historyIdx = t.key === 'up'
|
|
7480
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7481
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7482
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7483
|
+
state.caret = state.input.length;
|
|
7484
|
+
refreshMenu(state); renderFrame();
|
|
7485
|
+
}
|
|
7486
|
+
return;
|
|
7487
|
+
}
|
|
7310
7488
|
const insideW = Math.max(0, dims().cols - 2);
|
|
7311
7489
|
const layout = composerLayout(state, insideW - 3);
|
|
7312
7490
|
const dir = t.key === 'down' ? 1 : -1;
|
|
@@ -7319,6 +7497,8 @@ export async function startTUI(opts) {
|
|
|
7319
7497
|
state.composerSel = null;
|
|
7320
7498
|
renderFrame(); return;
|
|
7321
7499
|
}
|
|
7500
|
+
// Draft is on its last/first row: the key is free. Scroll the transcript
|
|
7501
|
+
// (useful while output streams and you want to read back).
|
|
7322
7502
|
scrollChat(state, t.key === 'up' ? 3 : -3);
|
|
7323
7503
|
renderFrame(); return;
|
|
7324
7504
|
}
|
|
@@ -7327,13 +7507,36 @@ export async function startTUI(opts) {
|
|
|
7327
7507
|
if (t.key === 'up' || t.key === 'down') {
|
|
7328
7508
|
const overlay = state.picker || state.form || state.panel || state.menuOpen
|
|
7329
7509
|
|| state.mentionOpen || state.editor;
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7510
|
+
if (!overlay) {
|
|
7511
|
+
const cur = state.input || '';
|
|
7512
|
+
const insideW = Math.max(0, dims().cols - 2);
|
|
7513
|
+
const layout = composerLayout(state, insideW - 3);
|
|
7514
|
+
const dir = t.key === 'down' ? 1 : -1;
|
|
7515
|
+
const targetRow = layout.caretRow + dir;
|
|
7516
|
+
// The composer is MULTI-LINE: ↑/↓ move the caret between its rows
|
|
7517
|
+
// first, and only when it is already on the first/last row does the
|
|
7518
|
+
// key fall through to history. That is the usual shell/editor rule —
|
|
7519
|
+
// typing a paragraph must not have ↑ yank the draft away.
|
|
7520
|
+
if (cur !== '' && targetRow >= 0 && targetRow < layout.rows.length) {
|
|
7521
|
+
const starts = rowStartOffsets(cur, layout.rows.length, insideW);
|
|
7522
|
+
const colInRow = Math.max(0, layout.caretCol - 1);
|
|
7523
|
+
const targetStart = starts[targetRow] != null ? starts[targetRow] : 0;
|
|
7524
|
+
state.caret = Math.min(cur.length, targetStart + colInRow);
|
|
7525
|
+
state.composerSel = null;
|
|
7526
|
+
renderFrame();
|
|
7527
|
+
return;
|
|
7528
|
+
}
|
|
7529
|
+
// First/last row (or an empty composer): this is history's turn.
|
|
7530
|
+
if (state.history.length) {
|
|
7531
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7532
|
+
state.historyIdx = t.key === 'up'
|
|
7533
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7534
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7535
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7536
|
+
state.caret = state.input.length;
|
|
7537
|
+
refreshMenu(state); renderFrame();
|
|
7538
|
+
}
|
|
7539
|
+
return;
|
|
7337
7540
|
}
|
|
7338
7541
|
}
|
|
7339
7542
|
}
|
|
@@ -7649,15 +7852,20 @@ export async function startTUI(opts) {
|
|
|
7649
7852
|
}
|
|
7650
7853
|
if (t.key === 'up' || t.key === 'down') {
|
|
7651
7854
|
const cur = state.input || '';
|
|
7652
|
-
if (cur === ''
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7855
|
+
if (cur === '') {
|
|
7856
|
+
// Empty composer: history owns the key. With NO history the key does
|
|
7857
|
+
// nothing — it must not silently scroll the transcript.
|
|
7858
|
+
if (state.history.length) {
|
|
7859
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7860
|
+
state.historyIdx = t.key === 'up'
|
|
7861
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7862
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7863
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7864
|
+
state.caret = state.input.length;
|
|
7865
|
+
state.composerSel = null;
|
|
7866
|
+
refreshMenu(state); renderFrame();
|
|
7867
|
+
}
|
|
7868
|
+
return;
|
|
7661
7869
|
}
|
|
7662
7870
|
const insideW = Math.max(0, dims().cols - 2);
|
|
7663
7871
|
const layout = composerLayout(state, insideW - 3);
|
|
@@ -7671,10 +7879,23 @@ export async function startTUI(opts) {
|
|
|
7671
7879
|
state.composerSel = null;
|
|
7672
7880
|
renderFrame(); return;
|
|
7673
7881
|
}
|
|
7674
|
-
|
|
7675
|
-
|
|
7882
|
+
// Caret is already on the first/last row: the composer has no further use
|
|
7883
|
+
// for the key, so it becomes HISTORY. (Scrolling the transcript is still
|
|
7884
|
+
// available on PgUp/PgDn and the wheel — the arrows belong to the input.)
|
|
7885
|
+
if (state.history.length) {
|
|
7886
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7887
|
+
state.historyIdx = t.key === 'up'
|
|
7888
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7889
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7890
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7891
|
+
state.caret = state.input.length;
|
|
7892
|
+
state.composerSel = null;
|
|
7893
|
+
refreshMenu(state); renderFrame(); return;
|
|
7894
|
+
}
|
|
7895
|
+
return;
|
|
7676
7896
|
}
|
|
7677
7897
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
7898
|
+
// With the pointer ON the scrollbar, scroll one line per notch: the bar is
|
|
7678
7899
|
// With the pointer ON the scrollbar, scroll one line per notch: the bar is
|
|
7679
7900
|
// the precision control, and 3-line steps there feel jumpy on a long
|
|
7680
7901
|
// transcript. Everywhere else keeps the usual 3-line step.
|
|
@@ -7944,6 +8165,7 @@ export async function startTUI(opts) {
|
|
|
7944
8165
|
state.scroll = Math.min(maxScroll, Math.max(0, (state.scroll || 0) + delta));
|
|
7945
8166
|
}
|
|
7946
8167
|
|
|
8168
|
+
|
|
7947
8169
|
async function submit(forceText) {
|
|
7948
8170
|
// `forceText` means the text came from somewhere other than the composer
|
|
7949
8171
|
// (the queue drain). Clearing the composer in that case would throw away
|
|
@@ -7972,7 +8194,10 @@ export async function startTUI(opts) {
|
|
|
7972
8194
|
state.pulseStart = null;
|
|
7973
8195
|
}
|
|
7974
8196
|
|
|
7975
|
-
if (state.history[state.history.length - 1] !== text)
|
|
8197
|
+
if (state.history[state.history.length - 1] !== text) {
|
|
8198
|
+
state.history.push(text);
|
|
8199
|
+
saveHistory(state.history); // persist so ↑ survives a restart
|
|
8200
|
+
}
|
|
7976
8201
|
// Shell passthrough. The MODE is authoritative (the `!` never entered the
|
|
7977
8202
|
// buffer); a leading `!` in the text is still honoured so a pasted line like
|
|
7978
8203
|
// `!git status` behaves the same as typing it.
|
|
@@ -8100,6 +8325,14 @@ export async function startTUI(opts) {
|
|
|
8100
8325
|
state.rounds = (state.rounds || 0) + 1;
|
|
8101
8326
|
state._stepsBase = state.steps || 0;
|
|
8102
8327
|
state._turnSteps = 0;
|
|
8328
|
+
// Re-base the start animation to THIS instant and paint immediately.
|
|
8329
|
+
// submit() set startAnim before `running` was true, so nothing could render
|
|
8330
|
+
// yet; by the time a frame was actually drawn the 1s animation had already
|
|
8331
|
+
// run out and the type-out was never seen. Stamping it here — the first
|
|
8332
|
+
// moment the row can be painted — makes the animation start on screen.
|
|
8333
|
+
state.startAnim = { start: Date.now() };
|
|
8334
|
+
state.pulseStart = null;
|
|
8335
|
+
renderFrame();
|
|
8103
8336
|
if (session) session.rounds = state.rounds;
|
|
8104
8337
|
// system 固定在开头,且不持久化到 session(否则每轮都会堆一条)。
|
|
8105
8338
|
// 历史里的旧 system 一律跳过,统一使用当前 system prompt:
|
|
@@ -8136,21 +8369,10 @@ export async function startTUI(opts) {
|
|
|
8136
8369
|
// prompt. Injected LAST and deepest-last so a nested file outranks the root one,
|
|
8137
8370
|
// which is the precedence order codex documents. Previously /init wrote this
|
|
8138
8371
|
// file and nothing ever loaded it.
|
|
8139
|
-
|
|
8372
|
+
const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state.workspace || cfg.workspace);
|
|
8140
8373
|
if (agents) {
|
|
8141
8374
|
sysText += '\n\n' + agents;
|
|
8142
8375
|
}
|
|
8143
|
-
// GIT CONTEXT: the current branch and how many files are uncommitted. Cheap
|
|
8144
|
-
// (two git calls) and it stops the model guessing whether the tree is dirty
|
|
8145
|
-
// or which branch it is on. Skipped silently outside a repository.
|
|
8146
|
-
{
|
|
8147
|
-
const gitCwd = state.cwd || state.workspace || cfg.workspace;
|
|
8148
|
-
let gitsum = '';
|
|
8149
|
-
try { gitsum = gitmod.summary(gitCwd); } catch { gitsum = ''; }
|
|
8150
|
-
if (gitsum) {
|
|
8151
|
-
sysText += '\n\nGIT WORKING TREE (this repository, as of now):\n' + gitsum;
|
|
8152
|
-
}
|
|
8153
|
-
}
|
|
8154
8376
|
let messages = [{ role: 'system', content: sysText }];
|
|
8155
8377
|
if (session.messages && session.messages.length) {
|
|
8156
8378
|
for (const m of session.messages) {
|
|
@@ -8167,10 +8389,10 @@ export async function startTUI(opts) {
|
|
|
8167
8389
|
// The approved-plan handoff arrives as a SYSTEM message: it is an instruction
|
|
8168
8390
|
// from the harness, not something the user typed, and it must not show up as a
|
|
8169
8391
|
// user bubble or be persisted as one.
|
|
8170
|
-
|
|
8171
|
-
// 只持久化对话本身,system 不进 session
|
|
8392
|
+
messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
|
|
8393
|
+
// 只持久化对话本身,system 不进 session。转折保存交给 turn 结束处
|
|
8394
|
+
// (agent.run 之后),中途不写盘以保持发消息流畅。
|
|
8172
8395
|
Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
|
|
8173
|
-
sess.saveSession(session);
|
|
8174
8396
|
|
|
8175
8397
|
if (state.plan) {
|
|
8176
8398
|
cfg.toolFilter = ['Read', 'Grep', 'Glob'];
|
|
@@ -8737,19 +8959,23 @@ export async function startTUI(opts) {
|
|
|
8737
8959
|
// Appending streamed text can also add rows (the message wraps as it grows).
|
|
8738
8960
|
// While the user is scrolled up, compensate so their view does not shift — the
|
|
8739
8961
|
// same anchoring rule addChat applies for a whole new message.
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
//
|
|
8745
|
-
//
|
|
8746
|
-
//
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8962
|
+
function anchorScroll() {
|
|
8963
|
+
// The arithmetic lives in anchorScrollNext() (module level, unit-tested): it
|
|
8964
|
+
// takes the current chat row count and returns the new `scroll`.
|
|
8965
|
+
// * the baseline is refreshed even while the view is pinned at the bottom,
|
|
8966
|
+
// so the first append after the user scrolls up has a real delta;
|
|
8967
|
+
// * the compensation is the rows added since the previous paint.
|
|
8968
|
+
//
|
|
8969
|
+
// The count comes from state._chatTotal, which composeFrame() refreshes on
|
|
8970
|
+
// every paint. Calling renderChatLines() here (as this used to) laid out the
|
|
8971
|
+
// WHOLE transcript a second time on every streamed chunk — a full ~25k-row
|
|
8972
|
+
// render per chunk on top of the windowed one, which is what made markdown
|
|
8973
|
+
// output appear to stall.
|
|
8974
|
+
const rows = state._chatTotal || 0;
|
|
8975
|
+
state.scroll = anchorScrollNext(state, rows);
|
|
8751
8976
|
}
|
|
8752
8977
|
|
|
8978
|
+
|
|
8753
8979
|
// Live split of the streamed reply into prose vs. <plan> block.
|
|
8754
8980
|
// While Plan mode is on and the model is inside <plan>…</plan>, the text is
|
|
8755
8981
|
// routed into a `plan` bubble so it renders in its frame AS IT ARRIVES — the old
|