@hncode/hncode 0.3.3 → 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
@@ -34,7 +34,7 @@ import * as upd from './update.js';
34
34
  import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
35
35
  import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
36
36
  import { renderSwarmProgress } from './swarm-progress.js';
37
- import { sortedTasks, getTask, settleTask, STATUS_LABEL } from './agent-task.js';
37
+ import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
38
38
  import { localVersion } from './version.js';
39
39
  import { listSkills, readSkill, importSkill, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
40
40
  import { loadHooks, describeHooks, runShellHooks, HOOK_EVENTS } from './hooks.js';
@@ -97,11 +97,18 @@ export const COMMANDS = [
97
97
  { name: 'branch', desc: 'List, create, or switch git branches', priority: 62, argumentHint: '[list | <name> | -c <name>]' },
98
98
  { name: 'worktree', desc: 'List, add, or remove git worktrees for parallel work', priority: 60, argumentHint: '[list | add <dir> [branch] | remove <dir>]' },
99
99
  { name: 'pr', desc: 'Show the URL to open a pull request for the current branch', priority: 60, argumentHint: '[base]' },
100
+ { name: 'add', aliases: ['stage'], desc: 'Stage changes (all by default, or given paths)', priority: 62, argumentHint: '[<path> ...]' },
101
+ { name: 'diff', desc: 'Show uncommitted changes', priority: 62, argumentHint: '[--staged] [<path>]' },
102
+ { name: 'log', desc: 'Show recent commits', priority: 62, argumentHint: '[count] [<path>]' },
103
+ { name: 'push', desc: 'Push the current branch (asks first)', priority: 62, argumentHint: '[branch] [-f]' },
104
+ { name: 'stash', desc: 'Stash changes: list / push / pop', priority: 60, argumentHint: '[list | push [msg] | pop]' },
105
+ { name: 'rebase', desc: 'Rebase the current branch onto another (asks first)', priority: 60, argumentHint: '<branch>' },
100
106
  { name: 'cache', desc: 'Toggle prompt caching for long stable prefixes (saves input tokens)', priority: 58, argumentHint: '[on|off]' },
101
107
  { name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
102
108
  { name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
103
109
  { name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
104
110
  { name: 'auto-update', desc: 'Toggle automatic background updates (check at startup + every 30 min)', priority: 40, argumentHint: '[on|off]' },
111
+ { name: 'auto-compact', aliases: ['autocompact'], desc: 'Toggle automatic context compaction at 85% of the window', priority: 40, argumentHint: '[on|off]' },
105
112
  { name: 'version', desc: 'Show version information', priority: 20 },
106
113
  { name: 'exit', aliases: ['quit', 'q'], desc: 'Exit the application', priority: 20 },
107
114
  ].sort((a, b) => (b.priority || 0) - (a.priority || 0) || a.name.localeCompare(b.name));
@@ -254,7 +261,7 @@ export const TIPS = [
254
261
  '/yolo auto-approves anything inside the workspace — risky paths still ask',
255
262
  '/auto never interrupts you; everything runs and is decided automatically',
256
263
  'Shift+Arrow selects text in the composer; Backspace or Delete removes it',
257
- 'Ctrl+J inserts a newline without sending the message',
264
+ 'Shift+Enter inserts a newline without sending the message',
258
265
  'Ctrl+T expands or collapses the todo panel',
259
266
  'Ctrl+O expands or collapses tool output, thinking blocks and Edit diffs',
260
267
  'Ctrl+B moves a long-running foreground Bash command to the background',
@@ -670,17 +677,25 @@ function wrapWords(str, width) {
670
677
  width = Math.max(1, width | 0);
671
678
  const out = [];
672
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;
673
687
  // Expand tabs first: visualCol counts '\t' as 0 columns but the terminal
674
688
  // advances to the next tab stop, which made the row wider than computed and
675
689
  // wrapped it onto the following line.
676
690
  const words = expandTabs(str).split(' ');
677
- for (let w of words) {
691
+ for (const w of words) {
692
+ const wW = visualCol(w);
678
693
  // A word longer than the line must be hard-split. This MUST measure visual
679
694
  // columns (not string length): CJK characters occupy two cells, so slicing
680
695
  // by `.length` would emit lines twice as wide as the box and the terminal
681
696
  // would wrap them onto the next row, corrupting the layout.
682
- if (visualCol(w) > width) {
683
- if (line) { out.push(line); line = ''; }
697
+ if (wW > width) {
698
+ if (line) { out.push(line); line = ''; lineW = 0; }
684
699
  let chunk = '';
685
700
  let cw = 0;
686
701
  for (const ch of w) {
@@ -688,12 +703,18 @@ function wrapWords(str, width) {
688
703
  if (cw + chw > width) { out.push(chunk); chunk = ''; cw = 0; }
689
704
  chunk += ch; cw += chw;
690
705
  }
691
- if (chunk) line = chunk;
706
+ if (chunk) { line = chunk; lineW = cw; }
692
707
  continue;
693
708
  }
694
- const cand = line ? line + ' ' + w : w;
695
- if (visualCol(cand) <= width) line = cand;
696
- 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
+ }
697
718
  }
698
719
  if (line) out.push(line);
699
720
  else if (out.length === 0) out.push('');
@@ -1561,6 +1582,37 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1561
1582
  return out;
1562
1583
  }
1563
1584
 
1585
+ // A BACKGROUND TASK lifecycle card (kimi-style):
1586
+ // ● agent task completed in background (review the diff)
1587
+ // ✗ bash task failed in background (npm test · exit 1)
1588
+ // The bullet is `●` normally and `✗` for a failure; the colour follows the
1589
+ // phase. The task's conclusion sits underneath, indented, and is capped like
1590
+ // a tool result so one chatty subagent cannot flood the transcript.
1591
+ if (msg.role === 'bg_task') {
1592
+ const card = msg.card || { phase: 'completed', headline: 'background task finished', detail: undefined };
1593
+ const tone = card.phase === 'started' ? C.cyan : card.phase === 'completed' ? C.green : C.red;
1594
+ const bullet = card.phase === 'failed' ? '✗ ' : '● ';
1595
+ const out = [];
1596
+ const detail = card.detail ? col(` (${card.detail})`, C.gray) : '';
1597
+ out.push({ text: col(bullet, tone) + col(card.headline, tone) + detail, ind: '', raw: true });
1598
+ const body = String(msg.text || '').trim();
1599
+ if (body) {
1600
+ const indent = ' ';
1601
+ const avail = Math.max(1, width - visualCol(indent));
1602
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
1603
+ const MAX = expanded ? 200 : 6;
1604
+ for (const ln of lines.slice(0, MAX)) {
1605
+ for (const w of (ln === '' ? [''] : wrapWords(ln, avail))) {
1606
+ out.push({ text: col(w, C.gray), ind: indent, raw: true });
1607
+ }
1608
+ }
1609
+ if (lines.length > MAX) {
1610
+ out.push({ text: col(`… ${lines.length - MAX} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
1611
+ }
1612
+ }
1613
+ return out;
1614
+ }
1615
+
1564
1616
  // Tool output: a single "↳ " marker on the first line, then the output
1565
1617
  // indented by 2 columns. Long output is capped (kimi caps result previews
1566
1618
  // too) so one command cannot flood the whole transcript.
@@ -2171,14 +2223,34 @@ export function argStreamLineCount(st, key) {
2171
2223
  // streamed (assistant text and a Write's content only ever grow), and is written
2172
2224
  // once when a turn completes. A large equal-length replacement does not occur on
2173
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.
2174
2233
  const FP_VERBATIM_MAX = 4096;
2234
+ const FP_CACHE_MAX = 8192;
2235
+ const fpCache = new Map();
2175
2236
  function fp(s) {
2176
2237
  if (s == null) return '0';
2177
- 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;
2178
2241
  const n = str.length;
2179
- 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;
2180
2251
  }
2181
2252
 
2253
+
2182
2254
  // Fingerprint of a tool-args object for the render-cache key. `JSON.stringify`
2183
2255
  // was used here and stringified EVERY argument — including a streaming Write's
2184
2256
  // whole file content — on every frame. The rendered tool line only shows the key
@@ -2196,82 +2268,158 @@ function fpArgs(args) {
2196
2268
  return out;
2197
2269
  }
2198
2270
 
2199
- function renderChatLines(state, w) {
2271
+ export function renderChatLines(state, w, viewport) {
2200
2272
  const out = [];
2201
2273
  const expanded = !!state.expanded;
2202
- // One blank column on the left of the whole transcript, so the markers (❯, ●,
2203
- // ↳) do not sit flush against the screen edge.
2204
2274
  const PAD = ' ';
2205
2275
  const padW = visualCol(PAD);
2206
2276
  const innerW = Math.max(1, w - padW);
2207
- for (const msg of state.chat) {
2208
- // A `user` / `queued` message is drawn inside a COMPOSER-STYLE BOX: rounded
2209
- // corners with `─`, and a `│` down each side — so a sent prompt looks exactly
2210
- // like the input box it was typed into.
2211
- // `steer` gets NO box: it is injected into the turn already in progress, so a
2212
- // 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];
2213
2355
  const bordered = msg.role === 'user' || msg.role === 'queued' || msg.role === 'bash';
2214
- // A bash-mode echo uses the violet shellMode frame — same hue as kimi.
2215
2356
  const boxColor = msg.role === 'bash' ? C.shellMode : C.border;
2216
- // The box spans one column less than the full inner width, so the right edge
2217
- // has a 1-column margin (matching the left margin from `PAD`).
2218
- const boxW = innerW - 2; // total box width, including both │
2219
- const boxInner = Math.max(1, boxW - 2); // span the two │s occupy
2220
- // One column of padding inside each wall, mirroring the composer box.
2221
- const boxPad = Math.min(1, Math.max(0, boxInner - 1));
2222
- const boxText = Math.max(1, boxInner - boxPad * 2);
2223
- const topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
2224
- const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
2225
- // A box always needs its TOP border — including when the message is the first
2226
- // row of the transcript. (The earlier `out.length > 0` guard came from the
2227
- // rule-only version, where a leading divider looked wrong; a box without a
2228
- // top is simply broken.) For adjacent bordered messages the previous box's
2229
- // bottom already serves as this one's top, so skip the duplicate.
2230
- if (bordered && out[out.length - 1] !== topRule) out.push(topRule);
2231
- // The cache key MUST include everything messageLines() reads. toolArgs was
2232
- // missing, so a tool row rendered at `tool_start` (args still {}) kept its
2233
- // stale "Using Bash" line even after `tool_use` delivered the command —
2234
- // the row only appeared once some OTHER keyed field changed. liveOutput is
2235
- // keyed because it grows while a command runs; `failed` flips the bullet
2236
- // and the result colour red once the tool reports a non-zero exit.
2237
- // The row WIDTH is keyed (not `w`) because the rows are laid out to it:
2238
- // `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
2239
- const rowW = bordered ? boxText : innerW;
2240
- // The cache key MUST include everything messageLines() reads. `spin` is
2241
- // keyed ONLY for a pending TOOL row: formatToolLine sweeps the tool name's
2242
- // colour with it. The pending THINKING row deliberately does not key it —
2243
- // its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
2244
- // the 80ms tick no longer re-wraps the reasoning preview every frame.
2245
- const spinKey = (msg.role === 'tool' && msg.pending) ? (state.spin || 0) : 0;
2246
- const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${fp(msg.text)}\u0000${fp(msg.streamContent)}`
2247
- + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${fpArgs(msg.toolArgs)}`
2248
- + `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
2249
- let cached = msg._cache;
2250
- if (!cached || cached.key !== key) {
2251
- const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin, state.pulseStart);
2252
- cached = { key, rows: rows.map(rowToLine) };
2253
- msg._cache = cached;
2254
- }
2255
- // Swap the live spinner frame into any row that carries the placeholder.
2256
- // Done AFTER the cache, so the glyph animates without invalidating it.
2257
- const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
2258
- const live = (r) => (r.indexOf(SPIN_PLACEHOLDER) >= 0 ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
2259
- // The margin is added here (outside the cache) so it is not baked into the
2260
- // cached rows. A bordered message also gets its side walls, laid out to the
2261
- // box's inner width so it lines up with the composer exactly.
2357
+ let topRule = null, botRule = null, boxPad = 1, boxText = innerW;
2262
2358
  if (bordered) {
2263
- const pad = ' '.repeat(boxPad);
2264
- for (const r of cached.rows) {
2265
- 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));
2266
2387
  }
2388
+ if (bordered) out.push(botRule);
2267
2389
  } else {
2268
- for (const r of cached.rows) out.push(PAD + live(r));
2390
+ for (let i = 0; i < len; i++) out.push('');
2269
2391
  }
2270
- if (bordered && out[out.length - 1] !== botRule) out.push(botRule);
2392
+ cursor += len;
2271
2393
  }
2272
2394
  return out;
2273
2395
  }
2274
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
+
2275
2423
  // Serialise a Line (array of spans) to an ANSI string.
2276
2424
  function lineToString(line) {
2277
2425
  let s = '';
@@ -2607,11 +2755,22 @@ export function refreshGitInfo(state, opts = {}) {
2607
2755
  if (!force && state.running) return;
2608
2756
  state._gitCwd = cwd;
2609
2757
  state._gitAt = now;
2610
- try {
2611
- state._gitInfo = gitmod.statuslineInfo(cwd);
2612
- } catch {
2613
- state._gitInfo = null; // a git failure must never break rendering
2614
- }
2758
+ // ASYNC: does not block the caller. On the startup path this is what lets the
2759
+ // first frame draw immediately — the badge appears a moment later when git
2760
+ // answers, instead of freezing the TUI behind four spawnSync calls. `seq`
2761
+ // discards a stale reply if another refresh started while this one ran.
2762
+ const seq = (state._gitSeq = (state._gitSeq || 0) + 1);
2763
+ gitmod.statuslineInfoAsync(cwd)
2764
+ .then((info) => {
2765
+ if (state._gitSeq !== seq) return; // superseded by a newer refresh
2766
+ state._gitInfo = info;
2767
+ if (typeof state._requestRender === 'function') state._requestRender();
2768
+ })
2769
+ .catch(() => {
2770
+ if (state._gitSeq !== seq) return;
2771
+ state._gitInfo = null; // a git failure must never break rendering
2772
+ if (typeof state._requestRender === 'function') state._requestRender();
2773
+ });
2615
2774
  }
2616
2775
 
2617
2776
  // ---- pure frame composer (no TTY side effects) ----
@@ -3001,8 +3160,15 @@ export function composeFrame(state, cols, rows) {
3001
3160
  // this too and appended a whole extra viewport of rows ON TOP of the editor.
3002
3161
  // The frame then overflowed, the top trim ate the editor's own title and text,
3003
3162
  // and /personal & /set-system-prompt showed nothing but a stray caret.
3004
- if (!state.editor && !state.panel && !state.form && !state.picker) {
3005
- 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 });
3006
3172
  if (state.selection && state.selection.anchor && state.selection.head) {
3007
3173
  // Apply selection highlight directly on ANSI strings
3008
3174
  for (let i = 0; i < chat.length; i++) {
@@ -3642,6 +3808,46 @@ function fieldCaretCol(field, active) {
3642
3808
  const c = Math.max(0, Math.min(shown.length, field.caret || 0));
3643
3809
  return visualCol(shown.slice(0, c));
3644
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
+ }
3645
3851
 
3646
3852
  // ---- state factory ----
3647
3853
  export function makeState({ cfg, session, opts }) {
@@ -3772,7 +3978,7 @@ export function makeState({ cfg, session, opts }) {
3772
3978
  notice: '',
3773
3979
  noticeKind: 'info',
3774
3980
  confirmExit: false,
3775
- history: [],
3981
+ history: loadHistory(), // restored from disk so ↑ works across restarts
3776
3982
  historyIdx: -1,
3777
3983
  _tipIndex: 0,
3778
3984
  };
@@ -3803,6 +4009,9 @@ export function reconstructChat(s) {
3803
4009
  const shellByAnchor = new Map();
3804
4010
  const shells = (s && s.shellHistory) || [];
3805
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;
3806
4015
  const a = Number(sh.anchor != null ? sh.anchor : 0);
3807
4016
  if (!shellByAnchor.has(a)) shellByAnchor.set(a, []);
3808
4017
  shellByAnchor.get(a).push(sh);
@@ -3810,8 +4019,11 @@ export function reconstructChat(s) {
3810
4019
  // Emit the shell block for anchor `a` (command + its output) onto `out`.
3811
4020
  const emitShells = (out, a) => {
3812
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;
3813
4025
  out.push({ role: 'bash', text: String(sh.cmd || '') });
3814
- 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 });
3815
4027
  }
3816
4028
  };
3817
4029
 
@@ -3819,6 +4031,11 @@ export function reconstructChat(s) {
3819
4031
  const msgs = (s && s.messages) || [];
3820
4032
  for (let mi = 0; mi < msgs.length; mi++) {
3821
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;
3822
4039
  const role = m.role;
3823
4040
  const text = typeof m.content === 'string' ? m.content : '';
3824
4041
  // Inject shell commands that anchored BEFORE this message (in the slot after
@@ -3827,7 +4044,8 @@ export function reconstructChat(s) {
3827
4044
  if (role === 'user') out.push({ role: 'user', text });
3828
4045
  else if (role === 'assistant') {
3829
4046
  out.push({ role: 'assistant', text });
3830
- for (const tc of (m.toolCalls || [])) {
4047
+ for (const tc of (Array.isArray(m.toolCalls) ? m.toolCalls : [])) {
4048
+ if (!tc || typeof tc !== 'object') continue;
3831
4049
  out.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
3832
4050
  }
3833
4051
  } else if (role === 'tool') {
@@ -3987,7 +4205,8 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
3987
4205
  { label: 'provider', sub: 'manage AI providers' },
3988
4206
  { label: 'statusline', sub: 'configure status line items' },
3989
4207
  { label: 'add-dir', sub: 'add an additional workspace directory' },
3990
- { label: 'auto-update', sub: 'check npm for updates at startup + every 30 min' },
4208
+ { label: 'auto-update', sub: `check npm for updates at startup + every 30 min — currently ${cfg.autoUpdate ? 'on' : 'off'}` },
4209
+ { label: 'auto-compact', sub: `summarize older history at 85% of the window — currently ${cfg.autoCompact === false ? 'off' : 'on'}` },
3991
4210
  ],
3992
4211
  onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
3993
4212
  });
@@ -4795,7 +5014,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
4795
5014
  '',
4796
5015
  'Shortcuts',
4797
5016
  ' Enter send the message',
4798
- ' Ctrl-J insert a newline',
5017
+ ' Shift+Enter insert a newline',
4799
5018
  ' Ctrl+Shift+C copy the selection (or the last answer)',
4800
5019
  ' Ctrl+Shift+V paste the clipboard (multi-line pastes collapse)',
4801
5020
  ' ↑ / ↓ input history (empty composer) · scroll the chat',
@@ -4858,6 +5077,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
4858
5077
  : 'Auto-update OFF');
4859
5078
  return;
4860
5079
  }
5080
+ case 'auto-compact': {
5081
+ // Toggle /auto-compact. Persists to config.toml so it survives restarts.
5082
+ const a = raw.toLowerCase();
5083
+ let want;
5084
+ if (a === 'on') want = true;
5085
+ else if (a === 'off') want = false;
5086
+ else want = cfg.autoCompact === false; // flip from the current state
5087
+ try { setConfigString('auto_compact', want ? 'true' : 'false'); }
5088
+ catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
5089
+ cfg.autoCompact = want;
5090
+ cfg.raw.auto_compact = want;
5091
+ app(want
5092
+ ? 'Auto-compaction ON — older history is summarized once a request reaches 85% of the context window'
5093
+ : 'Auto-compaction OFF — history is no longer trimmed automatically (use /compact manually)');
5094
+ return;
5095
+ }
4861
5096
  case 'update': {
4862
5097
  // Manual update check. This is the ONLY place an update is ever reported;
4863
5098
  // the automatic background check stays completely silent.
@@ -4928,28 +5163,35 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
4928
5163
  case 'statusline':
4929
5164
  openPicker({
4930
5165
  title: 'Status line items',
4931
- items: [
4932
- { label: 'permission mode', kind: 'toggle', isOn: state.slMode !== false },
4933
- { label: 'model name', kind: 'toggle', isOn: state.slModel !== false },
4934
- { label: 'thinking effort', kind: 'toggle', isOn: state.slEffort !== false },
4935
- { label: 'current directory', kind: 'toggle', isOn: state.slCwd !== false },
4936
- { label: 'git branch & diff', kind: 'toggle', isOn: state.slGit !== false },
4937
- { label: 'background tasks', kind: 'toggle', isOn: state.slTasks !== false },
4938
- { label: 'rotating tips', kind: 'toggle', isOn: state.slTips !== false },
4939
- ],
5166
+ items: (() => {
5167
+ // `sub` must be set here, not only in onPick: the picker renders
5168
+ // item.sub, so without an initial value every toggle showed a blank
5169
+ // state until the user toggled it once.
5170
+ const toggles = [
5171
+ ['permission mode', 'slMode'],
5172
+ ['model name', 'slModel'],
5173
+ ['thinking effort', 'slEffort'],
5174
+ ['current directory', 'slCwd'],
5175
+ ['git branch & diff', 'slGit'],
5176
+ ['background tasks', 'slTasks'],
5177
+ ['rotating tips', 'slTips'],
5178
+ ];
5179
+ return toggles.map(([label, key]) => {
5180
+ const isOn = state[key] !== false;
5181
+ return { label, key, kind: 'toggle', isOn, sub: isOn ? 'on' : 'off' };
5182
+ });
5183
+ })(),
4940
5184
  hint: '↑↓ navigate · Enter toggle · Esc close',
4941
5185
  onPick: (it) => {
4942
5186
  it.isOn = !it.isOn;
4943
5187
  it.sub = it.isOn ? 'on' : 'off';
4944
- const key = { 'permission mode': 'slMode', 'model name': 'slModel', 'thinking effort': 'slEffort', 'current directory': 'slCwd', 'git branch & diff': 'slGit', 'background tasks': 'slTasks', 'rotating tips': 'slTips' }[it.label];
4945
- state[key] = it.isOn;
4946
- if (key === 'slGit') refreshGitInfo(state, { force: true });
5188
+ state[it.key] = it.isOn;
5189
+ if (it.key === 'slGit') refreshGitInfo(state, { force: true });
4947
5190
  app(`Status line: ${it.label} → ${it.isOn ? 'shown' : 'hidden'}`);
4948
5191
  return false;
4949
5192
  },
4950
5193
  });
4951
5194
  return;
4952
-
4953
5195
  case 'export-md': {
4954
5196
  const msgs = session.messages || [];
4955
5197
  if (!msgs.length) { appErr('Nothing to export (empty session).'); return; }
@@ -5121,7 +5363,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
5121
5363
  return;
5122
5364
  }
5123
5365
  case 'plugins': {
5124
- 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;
5125
5371
  if (!loadedPlugins.length) { app('No plugins loaded. Check ~/.hncode/plugins/'); return; }
5126
5372
  const lines = ['Loaded plugins:', ...loadedPlugins.map((p) => ` • ${p.name} v${p.version} (${p.id})`)];
5127
5373
  if (pluginCommands.length) {
@@ -5220,6 +5466,122 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
5220
5466
  h.openPanel('hncode — git', lines);
5221
5467
  return;
5222
5468
  }
5469
+ case 'add': {
5470
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5471
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5472
+ // `/add` stages everything (`git add -A`); `/add <path> ...` stages just
5473
+ // those paths. This is the explicit staging step /commit does implicitly.
5474
+ const paths = raw.split(/\s+/).filter(Boolean);
5475
+ const r = gitmod.stage(paths, cwd);
5476
+ if (!r.ok) { appErr(`git add failed: ${r.error}`); return; }
5477
+ const staged = gitmod.stagedFiles(cwd);
5478
+ app(paths.length ? `Staged ${paths.length} path(s).` : 'Staged all changes.');
5479
+ say(`Index now holds ${staged.length} file(s):\n` + staged.map((f) => ' ' + f).join('\n'));
5480
+ return;
5481
+ }
5482
+ case 'diff': {
5483
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5484
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5485
+ const staged = /--staged|--cached/.test(raw);
5486
+ const path = raw.replace(/--staged|--cached/g, '').trim();
5487
+ const r = gitmod.diffText(cwd, { staged, path: path || undefined });
5488
+ if (!r.ok) { appErr(`git diff failed: ${r.error}`); return; }
5489
+ if (r.empty) { say(staged ? 'No staged changes.' : 'No uncommitted changes.'); return; }
5490
+ say((staged ? 'Staged changes' : 'Uncommitted changes') + (path ? ` in ${path}` : '') + ':\n' + r.text);
5491
+ return;
5492
+ }
5493
+ case 'log': {
5494
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5495
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5496
+ const parts = raw.split(/\s+/).filter(Boolean);
5497
+ const count = parts[0] && /^\d+$/.test(parts[0]) ? parts.shift() : undefined;
5498
+ const path = parts.join(' ') || undefined;
5499
+ const r = gitmod.logText(cwd, { count, path });
5500
+ if (!r.ok) { appErr(`git log failed: ${r.error}`); return; }
5501
+ if (r.empty) { say('No commits yet.'); return; }
5502
+ say(`Recent commits${path ? ` for ${path}` : ''}:\n` + r.text);
5503
+ return;
5504
+ }
5505
+ case 'push': {
5506
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5507
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5508
+ const parts = raw.split(/\s+/).filter(Boolean);
5509
+ const force = parts.includes('-f') || parts.includes('--force');
5510
+ const branchArg = parts.find((x) => !x.startsWith('-')) || '';
5511
+ const branch = branchArg || gitmod.currentBranch(cwd);
5512
+ const upstream = gitmod.hasUpstream(cwd, branch);
5513
+ const state2 = gitmod.aheadBehind(cwd, branch);
5514
+ const detail = `${branch}${upstream ? '' : ' (no upstream — will set one)'}${state2 ? ` — ${state2}` : ''}`;
5515
+ // Push is OUTBOUND: always confirm first, and say exactly what will happen.
5516
+ h.openPicker({
5517
+ title: `Push ${branch}?`,
5518
+ items: [
5519
+ { label: 'Push', sub: `git push ${force ? '--force-with-lease ' : ''}origin ${detail}` },
5520
+ { label: 'Cancel', sub: 'do not push' },
5521
+ ],
5522
+ searchable: false,
5523
+ hint: '↑↓ navigate · Enter select · Esc cancel',
5524
+ onPick: (it) => {
5525
+ if (it.label !== 'Push') { app('Push cancelled.'); return true; }
5526
+ const r = gitmod.push(cwd, { branch: branchArg || undefined, force, setUpstream: !upstream });
5527
+ if (!r.ok) appErr(`Push failed: ${r.error}`);
5528
+ else { app(`Pushed ${branch}.`); say(`git push output:\n${r.output}`); }
5529
+ return true;
5530
+ },
5531
+ });
5532
+ return;
5533
+ }
5534
+ case 'stash': {
5535
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5536
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5537
+ const parts = raw.split(/\s+/).filter(Boolean);
5538
+ const sub = (parts[0] || '').toLowerCase();
5539
+ if (!sub || sub === 'list') {
5540
+ const list = gitmod.stashList(cwd);
5541
+ say(list.length ? `Stashes (${list.length}):\n` + list.map((s) => ' ' + s).join('\n') : 'No stashes.');
5542
+ return;
5543
+ }
5544
+ if (sub === 'push' || sub === 'save') {
5545
+ const msg = parts.slice(1).join(' ') || undefined;
5546
+ const r = gitmod.stashPush(cwd, msg);
5547
+ if (!r.ok) { appErr(`git stash failed: ${r.error}`); return; }
5548
+ app('Stashed changes.'); say(r.output || 'Changes stashed.');
5549
+ return;
5550
+ }
5551
+ if (sub === 'pop') {
5552
+ const r = gitmod.stashPop(cwd);
5553
+ if (!r.ok) { appErr(`git stash pop failed: ${r.error}`); return; }
5554
+ app('Restored from stash.'); say(r.output || 'Stash popped.');
5555
+ return;
5556
+ }
5557
+ appErr('Usage: /stash [list] | push [message] | pop');
5558
+ return;
5559
+ }
5560
+ case 'rebase': {
5561
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5562
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5563
+ const onto = String(raw || '').trim();
5564
+ if (!onto) { appErr('Usage: /rebase <branch>'); return; }
5565
+ const cur = gitmod.currentBranch(cwd);
5566
+ // Rewrites history on the current branch: confirm before running.
5567
+ h.openPicker({
5568
+ title: `Rebase ${cur} onto ${onto}?`,
5569
+ items: [
5570
+ { label: 'Rebase', sub: `git rebase ${onto}` },
5571
+ { label: 'Cancel', sub: 'leave the branch as is' },
5572
+ ],
5573
+ searchable: false,
5574
+ hint: '↑↓ navigate · Enter select · Esc cancel',
5575
+ onPick: (it) => {
5576
+ if (it.label !== 'Rebase') { app('Rebase cancelled.'); return true; }
5577
+ const r = gitmod.rebase(cwd, onto);
5578
+ if (!r.ok) appErr(`Rebase failed (resolve conflicts, then /rebase --continue or git rebase --abort): ${r.error}`);
5579
+ else { app(`Rebased ${cur} onto ${onto}.`); say(r.output || 'Rebase complete.'); }
5580
+ return true;
5581
+ },
5582
+ });
5583
+ return;
5584
+ }
5223
5585
  case 'commit': {
5224
5586
  const cwd = state.cwd || cfg.workspace || process.cwd();
5225
5587
  if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
@@ -5389,13 +5751,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
5389
5751
  // Check if this is a plugin-registered command.
5390
5752
  if (isPluginCommand(cmd)) {
5391
5753
  const pc = findCommand(cmd);
5392
- if (pc && pc._plugin && typeof pc.run === 'function') {
5393
- 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 }); }
5394
5764
  catch (e) { appErr(`Plugin command "/${cmd}" failed: ${e.message}`); }
5395
5765
  return;
5396
5766
  }
5397
5767
  }
5398
5768
  // Unknown command: show error message
5769
+ // Unknown command: show error message
5399
5770
  appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
5400
5771
  return;
5401
5772
  }
@@ -5477,6 +5848,11 @@ function commandPathsAreInside(state, cmd, cwd) {
5477
5848
  function applyModel(state, cfg, next) {
5478
5849
  cfg.model = next.model; cfg.innerModel = next.innerModel; cfg.provider = next.provider;
5479
5850
  cfg.baseUrl = next.baseUrl; cfg.endpoint = next.endpoint; cfg.apiKey = next.apiKey; cfg.protocol = next.protocol;
5851
+ // Carry the resolved context window over too. resolveProvider/resolveModelArg
5852
+ // compute `maxContextTokens` for the NEW model, but it lives on the returned
5853
+ // object — without copying it here, cfg (and the gauge below) kept the previous
5854
+ // model's window after a switch.
5855
+ if (next.maxContextTokens != null) cfg.maxContextTokens = next.maxContextTokens;
5480
5856
  state.model = cfg.model; state.provider = cfg.provider;
5481
5857
  state.modelLabel = modelLabel(cfg) || cfg.model || '';
5482
5858
  state.seenThinking = false;
@@ -5730,30 +6106,12 @@ export async function startTUI(opts) {
5730
6106
  const stdout = process.stdout;
5731
6107
  const addChat = (msg) => {
5732
6108
  state.chat.push(normalizeMsg(msg));
5733
- // Only auto-follow when the view is ALREADY pinned to the bottom. Forcing
5734
- // scroll = 0 unconditionally yanked the user back down whenever new output
5735
- // arrived — so reading history mid-turn was impossible.
5736
- //
5737
- // This used to be `if ((state.scroll||0) === 0) state.scroll = 0;`, i.e. a
5738
- // NO-OP. The comment promised "auto-follow" but nothing re-anchored the view,
5739
- // so appending a tall message (a long Read/Bash result) pushed the whole
5740
- // transcript up by its height in a single frame instead of scrolling by one
5741
- // row — the "everything jumps" report. When the user IS scrolled up we leave
5742
- // the offset alone, which is the other half of the original intent.
5743
- if ((state.scroll || 0) === 0) {
5744
- state.scroll = 0; // pinned: keep following the newest message
5745
- } else {
5746
- // Scrolled up: grow the offset by exactly the number of rows this message
5747
- // added, so the content the user is reading does not move.
5748
- try {
5749
- const rows = renderChatLines(state, dims().cols).length;
5750
- if (state._addChatRows != null) {
5751
- const grew = Math.max(0, rows - state._addChatRows);
5752
- if (grew) state.scroll = (state.scroll || 0) + grew;
5753
- }
5754
- state._addChatRows = rows;
5755
- } catch { /* rendering is best-effort here; never break a message append */ }
5756
- }
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();
5757
6115
  renderFrame();
5758
6116
  };
5759
6117
  let noticeTimer = null;
@@ -5784,6 +6142,9 @@ export async function startTUI(opts) {
5784
6142
  if (out) stdout.write(out);
5785
6143
  }
5786
6144
  function renderFrame() { paintNow(); }
6145
+ // Let module-level helpers (refreshGitInfo) ask for a repaint once their async
6146
+ // data lands, without holding a reference to this closure.
6147
+ state._requestRender = renderFrame;
5787
6148
  // No frame-rate cap: paint on the next event-loop turn. Calls made within the
5788
6149
  // same tick still coalesce (paintScheduled), so a burst of stream deltas
5789
6150
  // produces one frame per tick at full speed instead of being throttled to
@@ -6151,11 +6512,46 @@ export async function startTUI(opts) {
6151
6512
  // and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
6152
6513
  warmClipboard();
6153
6514
  setTip(state);
6154
- // Populate the git badge before the first paint so the status line is correct
6155
- // immediately (from then on the 15s timer / turn-end keep it current).
6515
+ // Fill the git badge — asynchronously, AFTER the first paint. It used to run
6516
+ // before renderFrame() with a blocking spawnSync git, which added ~330ms to
6517
+ // startup; now the frame draws at once and the badge appears when git answers.
6156
6518
  refreshGitInfo(state, { force: true });
6157
6519
  renderFrame();
6158
6520
 
6521
+ // MCP: connect in the BACKGROUND, now that the UI is on screen. Each server
6522
+ // gets up to CONNECT_TIMEOUT_MS, so doing this before the first paint would
6523
+ // stall the whole TUI; instead we start it and report the outcome as a notice
6524
+ // ("MCP: connected to …") when it lands. Tools registered here become visible
6525
+ // to the model on its next turn.
6526
+ if (typeof opts.mcpConnect === 'function') {
6527
+ Promise.resolve()
6528
+ .then(() => opts.mcpConnect())
6529
+ .then((res) => {
6530
+ const servers = (res && res.servers) || [];
6531
+ if (!servers.length) return;
6532
+ // The notice line is a SINGLE status-line row, so report all servers in
6533
+ // one message — looping notice() per server only left the last one
6534
+ // visible. Failures are listed too, and the whole notice turns red when
6535
+ // any server failed.
6536
+ const okServers = servers.filter((s) => s.ok);
6537
+ const bad = servers.filter((s) => !s.ok);
6538
+ const parts = [];
6539
+ if (okServers.length) {
6540
+ const tools = okServers.reduce((n, s) => n + (s.toolCount || 0), 0);
6541
+ const names = okServers.map((s) => s.name).join(', ');
6542
+ parts.push(`MCP connected: ${names} (${tools} tool${tools === 1 ? '' : 's'})`);
6543
+ }
6544
+ if (bad.length) {
6545
+ parts.push(`MCP failed: ${bad.map((s) => s.name).join(', ')}`);
6546
+ }
6547
+ notice(parts.join(' · '), bad.length ? 'error' : 'info');
6548
+ // A connecting server may have changed the tool list; repaint so any
6549
+ // tool-count readout is current.
6550
+ renderFrame();
6551
+ })
6552
+ .catch(() => { /* MCP must never break the session */ });
6553
+ }
6554
+
6159
6555
  const tipTimer = setInterval(() => { setTip(state); renderFrame(); }, TIP_INTERVAL);
6160
6556
  // Git badge: a fixed 15s cadence. Deliberately NOT tied to the frame loop (that
6161
6557
  // spawned git per keystroke) and NOT run mid-turn (the agent is still writing;
@@ -7069,12 +7465,26 @@ export async function startTUI(opts) {
7069
7465
  renderFrame(); return;
7070
7466
  }
7071
7467
  if (t.key === 'up' || t.key === 'down') {
7072
- // ↑/↓ only scroll when the COMPOSER has no use for the key. While the
7073
- // agent streams you are usually typing the next instruction, so a
7074
- // multi-line draft must still move its caret — swallowing the key for
7075
- // scrolling unconditionally is what made ↑/↓ "always scroll". When the
7076
- // caret is already on the first/last row the key falls through to the
7077
- // 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
+ }
7078
7488
  const insideW = Math.max(0, dims().cols - 2);
7079
7489
  const layout = composerLayout(state, insideW - 3);
7080
7490
  const dir = t.key === 'down' ? 1 : -1;
@@ -7087,6 +7497,8 @@ export async function startTUI(opts) {
7087
7497
  state.composerSel = null;
7088
7498
  renderFrame(); return;
7089
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).
7090
7502
  scrollChat(state, t.key === 'up' ? 3 : -3);
7091
7503
  renderFrame(); return;
7092
7504
  }
@@ -7095,13 +7507,36 @@ export async function startTUI(opts) {
7095
7507
  if (t.key === 'up' || t.key === 'down') {
7096
7508
  const overlay = state.picker || state.form || state.panel || state.menuOpen
7097
7509
  || state.mentionOpen || state.editor;
7098
- const cur = state.input || '';
7099
- if (!overlay && cur === '' && state.history.length) {
7100
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
7101
- state.historyIdx = t.key === 'up' ? Math.max(0, state.historyIdx - 1) : Math.min(state.history.length, state.historyIdx + 1);
7102
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
7103
- state.caret = state.input.length;
7104
- 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;
7105
7540
  }
7106
7541
  }
7107
7542
  }
@@ -7417,15 +7852,20 @@ export async function startTUI(opts) {
7417
7852
  }
7418
7853
  if (t.key === 'up' || t.key === 'down') {
7419
7854
  const cur = state.input || '';
7420
- if (cur === '' && state.history.length) {
7421
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
7422
- state.historyIdx = t.key === 'up'
7423
- ? Math.max(0, state.historyIdx - 1)
7424
- : Math.min(state.history.length, state.historyIdx + 1);
7425
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
7426
- state.caret = state.input.length;
7427
- state.composerSel = null;
7428
- 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;
7429
7869
  }
7430
7870
  const insideW = Math.max(0, dims().cols - 2);
7431
7871
  const layout = composerLayout(state, insideW - 3);
@@ -7439,10 +7879,23 @@ export async function startTUI(opts) {
7439
7879
  state.composerSel = null;
7440
7880
  renderFrame(); return;
7441
7881
  }
7442
- scrollChat(state, t.key === 'up' ? 3 : -3);
7443
- 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;
7444
7896
  }
7445
7897
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
7898
+ // With the pointer ON the scrollbar, scroll one line per notch: the bar is
7446
7899
  // With the pointer ON the scrollbar, scroll one line per notch: the bar is
7447
7900
  // the precision control, and 3-line steps there feel jumpy on a long
7448
7901
  // transcript. Everywhere else keeps the usual 3-line step.
@@ -7712,6 +8165,7 @@ export async function startTUI(opts) {
7712
8165
  state.scroll = Math.min(maxScroll, Math.max(0, (state.scroll || 0) + delta));
7713
8166
  }
7714
8167
 
8168
+
7715
8169
  async function submit(forceText) {
7716
8170
  // `forceText` means the text came from somewhere other than the composer
7717
8171
  // (the queue drain). Clearing the composer in that case would throw away
@@ -7740,7 +8194,10 @@ export async function startTUI(opts) {
7740
8194
  state.pulseStart = null;
7741
8195
  }
7742
8196
 
7743
- 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
+ }
7744
8201
  // Shell passthrough. The MODE is authoritative (the `!` never entered the
7745
8202
  // buffer); a leading `!` in the text is still honoured so a pasted line like
7746
8203
  // `!git status` behaves the same as typing it.
@@ -7868,6 +8325,14 @@ export async function startTUI(opts) {
7868
8325
  state.rounds = (state.rounds || 0) + 1;
7869
8326
  state._stepsBase = state.steps || 0;
7870
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();
7871
8336
  if (session) session.rounds = state.rounds;
7872
8337
  // system 固定在开头,且不持久化到 session(否则每轮都会堆一条)。
7873
8338
  // 历史里的旧 system 一律跳过,统一使用当前 system prompt:
@@ -7904,21 +8369,10 @@ export async function startTUI(opts) {
7904
8369
  // prompt. Injected LAST and deepest-last so a nested file outranks the root one,
7905
8370
  // which is the precedence order codex documents. Previously /init wrote this
7906
8371
  // file and nothing ever loaded it.
7907
- 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);
7908
8373
  if (agents) {
7909
8374
  sysText += '\n\n' + agents;
7910
8375
  }
7911
- // GIT CONTEXT: the current branch and how many files are uncommitted. Cheap
7912
- // (two git calls) and it stops the model guessing whether the tree is dirty
7913
- // or which branch it is on. Skipped silently outside a repository.
7914
- {
7915
- const gitCwd = state.cwd || state.workspace || cfg.workspace;
7916
- let gitsum = '';
7917
- try { gitsum = gitmod.summary(gitCwd); } catch { gitsum = ''; }
7918
- if (gitsum) {
7919
- sysText += '\n\nGIT WORKING TREE (this repository, as of now):\n' + gitsum;
7920
- }
7921
- }
7922
8376
  let messages = [{ role: 'system', content: sysText }];
7923
8377
  if (session.messages && session.messages.length) {
7924
8378
  for (const m of session.messages) {
@@ -7935,10 +8389,10 @@ export async function startTUI(opts) {
7935
8389
  // The approved-plan handoff arrives as a SYSTEM message: it is an instruction
7936
8390
  // from the harness, not something the user typed, and it must not show up as a
7937
8391
  // user bubble or be persisted as one.
7938
- messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
7939
- // 只持久化对话本身,system 不进 session。
8392
+ messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
8393
+ // 只持久化对话本身,system 不进 session。转折保存交给 turn 结束处
8394
+ // (agent.run 之后),中途不写盘以保持发消息流畅。
7940
8395
  Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
7941
- sess.saveSession(session);
7942
8396
 
7943
8397
  if (state.plan) {
7944
8398
  cfg.toolFilter = ['Read', 'Grep', 'Glob'];
@@ -8094,16 +8548,19 @@ export async function startTUI(opts) {
8094
8548
  renderSoon();
8095
8549
  return;
8096
8550
  }
8551
+ if (e.type === 'compacting') {
8552
+ // In-progress notice: the summary below is a full model round-trip, so
8553
+ // show that work is happening instead of leaving the turn silent.
8554
+ addChat({ role: 'system', text: `Context at ${fmtTokens(e.before || 0)} — auto-compacting…` });
8555
+ renderSoon();
8556
+ return;
8557
+ }
8097
8558
  if (e.type === 'compacted') {
8098
8559
  state.ctxTokens = e.after || state.ctxTokens;
8099
8560
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
8100
8561
  addChat({ role: 'system', text: `Context auto-compacted (${fmtTokens(e.before || 0)} → ${fmtTokens(e.after || 0)} tokens).` });
8101
8562
  return;
8102
8563
  }
8103
- if (e.type === 'nudge') {
8104
- // Silent internal signal - don't show to user
8105
- return;
8106
- }
8107
8564
  if (e.type === 'incomplete') {
8108
8565
  // Orange: this is a warning, not ordinary system output.
8109
8566
  addChat({ role: 'warn', text: `Stopped: ${e.reason}. The task may be unfinished — send another message to continue.` });
@@ -8127,8 +8584,16 @@ export async function startTUI(opts) {
8127
8584
  if (e.type === 'aborted') {
8128
8585
  const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
8129
8586
  if (t) t.pending = false;
8587
+ // Settle any RUNNING tool rows too. A tool that was mid-flight when the
8588
+ // user hit Esc never gets its tool_result, so its `pending` stayed true
8589
+ // and the `Using <Tool>` name kept pulsing forever. Clearing it drops the
8590
+ // name to plain cyan immediately.
8591
+ for (const m of state.chat) {
8592
+ if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; }
8593
+ }
8130
8594
  // Use a special role that won't show the ✓ icon
8131
8595
  state.chat.push({ role: 'aborted', text: C.red + 'interrupted' + C.reset });
8596
+ renderFrame();
8132
8597
  return;
8133
8598
  }
8134
8599
  if (e.type === 'steer') {
@@ -8299,12 +8764,12 @@ export async function startTUI(opts) {
8299
8764
  if (agent.ctx) {
8300
8765
  agent.ctx._onBackgroundTaskDone = (task) => {
8301
8766
  if (!task) return;
8302
- const status = task.status || 'completed';
8303
- const head = status === 'completed'
8304
- ? `[background agent finished: ${task.taskId}]`
8305
- : `[background agent ${status}: ${task.taskId}${task.stopReason ? ' - ' + task.stopReason : ''}]`;
8767
+ // kimi-style lifecycle card: bullet colour by phase, a headline naming
8768
+ // the kind (agent / bash / question task) and what happened, and a dim
8769
+ // detail line. The task's conclusion is kept as the expandable body.
8770
+ const card = backgroundTaskCard(task);
8306
8771
  const body = String(task.output || '').trim();
8307
- addChat({ role: 'system', text: body ? `${head}\n${body}` : head });
8772
+ addChat({ role: 'bg_task', card, text: body });
8308
8773
  };
8309
8774
  }
8310
8775
  await agent.run();
@@ -8494,19 +8959,23 @@ export async function startTUI(opts) {
8494
8959
  // Appending streamed text can also add rows (the message wraps as it grows).
8495
8960
  // While the user is scrolled up, compensate so their view does not shift — the
8496
8961
  // same anchoring rule addChat applies for a whole new message.
8497
- function anchorScroll() {
8498
- if ((state.scroll || 0) === 0) return; // pinned to the bottom: follow
8499
- const rows = renderChatLines(state, dims().cols).length;
8500
- const prev = state._anchorRows;
8501
- // On the first call after the user scrolled up, _anchorRows is unset — just
8502
- // record the current row count without adjusting scroll. Otherwise a large
8503
- // gap (prev=0 vs. rows=N) would jump the view to the top of the session.
8504
- if (prev != null) {
8505
- if (rows > prev) state.scroll = (state.scroll || 0) + (rows - prev);
8506
- }
8507
- 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);
8508
8976
  }
8509
8977
 
8978
+
8510
8979
  // Live split of the streamed reply into prose vs. <plan> block.
8511
8980
  // While Plan mode is on and the model is inside <plan>…</plan>, the text is
8512
8981
  // routed into a `plan` bubble so it renders in its frame AS IT ARRIVES — the old
@@ -8630,6 +9099,10 @@ export async function startTUI(opts) {
8630
9099
  failed: !!(m && m.failed),
8631
9100
  };
8632
9101
  if (m && Array.isArray(m.diff)) out.diff = m.diff;
9102
+ // Keep `card`: a background-task lifecycle card carries its phase/headline/
9103
+ // detail here (see the `bg_task` renderer). Dropping it left the row with the
9104
+ // fallback text instead of what actually happened.
9105
+ if (m && m.card) out.card = m.card;
8633
9106
  return out;
8634
9107
  }
8635
9108