@hncode/hncode 0.3.4 → 0.3.5

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/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
- 'Ctrl+J inserts a newline without sending the message',
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 (let w of words) {
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 (visualCol(w) > width) {
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 cand = line ? line + ' ' + w : w;
702
- if (visualCol(cand) <= width) line = cand;
703
- else { if (line) out.push(line); line = w; }
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
- return n <= FP_VERBATIM_MAX ? n + ':' + str : n + ':';
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
- for (const msg of state.chat) {
2246
- // A `user` / `queued` message is drawn inside a COMPOSER-STYLE BOX: rounded
2247
- // corners with `─`, and a `│` down each side — so a sent prompt looks exactly
2248
- // like the input box it was typed into.
2249
- // `steer` gets NO box: it is injected into the turn already in progress, so a
2250
- // box would falsely suggest a separate turn had started.
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
- // The box spans one column less than the full inner width, so the right edge
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 pad = ' '.repeat(boxPad);
2302
- for (const r of cached.rows) {
2303
- out.push(PAD + col('│', boxColor) + pad + fitAnsi(live(r), boxText) + pad + col('│', boxColor));
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 (const r of cached.rows) out.push(PAD + live(r));
2390
+ for (let i = 0; i < len; i++) out.push('');
2307
2391
  }
2308
- if (bordered && out[out.length - 1] !== botRule) out.push(botRule);
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
- if (!state.editor && !state.panel && !state.form && !state.picker) {
3054
- const chat = renderChatLines(state, w);
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
- ' Ctrl-J insert a newline',
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, loadedPlugins } = await import('../plugin.js');
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
- if (pc && pc._plugin && typeof pc.run === 'function') {
5582
- try { pc.run(raw, { state, cfg, session, h, app, appErr }); }
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
- // Only auto-follow when the view is ALREADY pinned to the bottom. Forcing
5928
- // scroll = 0 unconditionally yanked the user back down whenever new output
5929
- // arrived — so reading history mid-turn was impossible.
5930
- //
5931
- // This used to be `if ((state.scroll||0) === 0) state.scroll = 0;`, i.e. a
5932
- // NO-OP. The comment promised "auto-follow" but nothing re-anchored the view,
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
- // ↑/↓ only scroll when the COMPOSER has no use for the key. While the
7305
- // agent streams you are usually typing the next instruction, so a
7306
- // multi-line draft must still move its caret — swallowing the key for
7307
- // scrolling unconditionally is what made ↑/↓ "always scroll". When the
7308
- // caret is already on the first/last row the key falls through to the
7309
- // scroll below, so both behaviours are available.
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
- const cur = state.input || '';
7331
- if (!overlay && cur === '' && state.history.length) {
7332
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
7333
- state.historyIdx = t.key === 'up' ? Math.max(0, state.historyIdx - 1) : Math.min(state.history.length, state.historyIdx + 1);
7334
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
7335
- state.caret = state.input.length;
7336
- refreshMenu(state); renderFrame(); return;
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 === '' && state.history.length) {
7653
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
7654
- state.historyIdx = t.key === 'up'
7655
- ? Math.max(0, state.historyIdx - 1)
7656
- : Math.min(state.history.length, state.historyIdx + 1);
7657
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
7658
- state.caret = state.input.length;
7659
- state.composerSel = null;
7660
- refreshMenu(state); renderFrame(); return;
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
- scrollChat(state, t.key === 'up' ? 3 : -3);
7675
- renderFrame(); return;
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) state.history.push(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
- const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state.workspace || cfg.workspace);
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
- messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
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
- function anchorScroll() {
8741
- if ((state.scroll || 0) === 0) return; // pinned to the bottom: follow
8742
- const rows = renderChatLines(state, dims().cols).length;
8743
- const prev = state._anchorRows;
8744
- // On the first call after the user scrolled up, _anchorRows is unset — just
8745
- // record the current row count without adjusting scroll. Otherwise a large
8746
- // gap (prev=0 vs. rows=N) would jump the view to the top of the session.
8747
- if (prev != null) {
8748
- if (rows > prev) state.scroll = (state.scroll || 0) + (rows - prev);
8749
- }
8750
- state._anchorRows = rows;
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