@hncode/hncode 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.js CHANGED
@@ -427,6 +427,7 @@ export const TIPS = [
427
427
  'Queued messages wait for the turn to finish',
428
428
  '/steer injects a message into the running turn',
429
429
  'Ctrl+R reloads the current session from disk',
430
+ 'Alt+R re-fetches the provider\'s model list',
430
431
  '/help shows available commands at any time',
431
432
  '/undo rolls back the transcript AND the files',
432
433
  '/fork copies the session so you can branch',
@@ -551,8 +552,8 @@ export function reducedMotionOn(state) {
551
552
  * tool, a thinking preview) gets the same treatment from one place.
552
553
  */
553
554
  export function spinnerFrame(state, tick) {
554
- if (reducedMotionOn(state)) {
555
- const t = tick !== undefined ? tick : (state && state.spin) || 0;
555
+ if (reducedMotionOn(state)) {
556
+ const t = tick !== undefined ? tick : (state && state.spin) || 0;
556
557
  return { glyph: REDUCED_MOTION_DOT, dim: Math.floor(t / (REDUCED_MOTION_CYCLE_MS / 80)) % 2 === 1 };
557
558
  }
558
559
  const n = SPINNER.length;
@@ -646,7 +647,7 @@ export function sweepPulse(dist, half, edge = 'cosine', flat = 0) {
646
647
  * glint is a point travelling along the row, not a wash sweeping over it. It stops at one rather than
647
648
  * shrinking to zero because a two-character row still needs something to see.
648
649
  */
649
- export const LIGHT_FRACTION = 1 / 4;
650
+ export const LIGHT_FRACTION = 1 / 4;
650
651
 
651
652
  /**
652
653
  * Milliseconds per unit of `state.spin`.
@@ -678,7 +679,7 @@ export function lightWidth(n) {
678
679
  * palettes: the peak moves one letter at a time, and the row reads as a lamp travelling rather than as a
679
680
  * single pixel flashing.
680
681
  */
681
- export const TAIL_CHARS = 2;
682
+ export const TAIL_CHARS = 4;
682
683
 
683
684
 
684
685
  /**
@@ -693,7 +694,7 @@ export const TAIL_CHARS = 2;
693
694
  */
694
695
  function totalHalf(n) {
695
696
  const want = lightWidth(n) + 2 * TAIL_CHARS;
696
- const capped = Math.min(want, Math.max(2, n - 1));
697
+ const capped = Math.min(want, Math.max(2, n - 1));
697
698
  return capped / 2;
698
699
  }
699
700
 
@@ -712,19 +713,44 @@ function coreHalf(n) {
712
713
  *
713
714
  * At 30fps a frame is 33ms, which is what makes the crossing in `CROSS_MS` six frames long.
714
715
  */
715
- export const ANIM_FPS = 30;
716
+ export const ANIM_FPS = 30;
716
717
  export const FRAME_MS = 1000 / ANIM_FPS;
717
718
 
718
719
  /**
719
720
  * How long one step of the light lasts, in milliseconds.
720
721
  *
721
- * Claude Code runs two rates — `glimmerSpeed` is 50ms while a request is in flight and 200ms otherwise —
722
- * but only ONE of them is ever used here: both animated rows (Working, and a pending tool's name) are
723
- * shown while something is happening, so both take the fast rate. A second, unused rate was carried for a
724
- * while "for parity"; it was dead weight, and it made every timing assertion twice as long.
722
+ * FIXED at 80ms — fast enough that a sweep reads as motion, slow enough that the eye is not chasing it.
723
+ *
724
+ * It followed the output rate for a while. That was tried and dropped: a model writing slowly got a
725
+ * sweep so slow it looked stuck, a fast one got a blur, and the same row changed pace between turns for
726
+ * no reason the reader could see. One rate is calmer and predictable, which is what an ambient
727
+ * indicator needs to be.
725
728
  */
726
- export const STEP_MS = 50;
727
-
729
+ export const STEP_MS = 65;
730
+
731
+ /**
732
+ * The step for a TOOL name, slower than the Working row's.
733
+ *
734
+ * A tool name is short — `Read` is four characters — so at the Working row's rate the light crosses it in
735
+ * 560ms and the row is dark for the rest of the cycle. On screen that reads as a single flash, not as
736
+ * movement. 150ms per character gives a four-character name a 1.4s round: slow enough to follow, still
737
+ * obviously running.
738
+ */
739
+ const TOOL_STEP_MS = 150;
740
+
741
+ /**
742
+ * How long the light stays on one character, from how fast tokens are arriving.
743
+ *
744
+ * `tps` is output tokens per second for the step in flight. A fast stream should look fast — the row
745
+ * keeping a leisurely 50ms step while tokens pour in reads as the animation lagging behind the text it
746
+ * is supposed to accompany.
747
+ *
748
+ * The clamp is the important half. Under about 10 tok/s the model is thinking more than writing and the
749
+ * sweep slows down so it does not look frantic over a trickle; above about 80 tok/s it is a wall of text
750
+ * and the light moves quickly but never strobes. 28ms is just under a 33ms render frame, so a step is
751
+ * always painted at least once; 200ms is slow enough to read as deliberate without looking stuck.
752
+ */
753
+
728
754
 
729
755
 
730
756
 
@@ -739,16 +765,22 @@ export const STEP_MS = 50;
739
765
  * ended; `lightMargin` takes the larger of the two.
740
766
  */
741
767
  const ENTRY_EXIT_CHARS = 10;
742
-
743
- /**
744
- * The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger.
745
- *
746
- * One function so `lightAt` and `crossMs` cannot disagree about how far the light travels. When they did,
747
- * the sweep finished before the light had left the row and the tail stayed lit through the rest.
748
- */
749
- function lightMargin(n) {
750
- return Math.max(ENTRY_EXIT_CHARS, totalHalf(Math.max(1, n)));
751
- }
768
+ /**
769
+ * The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger —
770
+ * but CAPPED so the light reaches the text in reasonable time.
771
+ *
772
+ * Ten cells either side is what makes the timing match Claude Code on a long row. On a SHORT row it is
773
+ * most of the cycle: `Edit` is four characters, so the travel is 24 cells and the light spends the first
774
+ * 500ms still outside the text, showing nothing. A row that looks frozen for half a second and then
775
+ * blinks once reads as broken, which is exactly how the Using row was reported.
776
+ *
777
+ * The cap is `n + 4` per side at most, so a short row spends a few steps entering rather than ten, and a
778
+ * long row is unchanged.
779
+ */
780
+ function lightMargin(n) {
781
+ const half = totalHalf(Math.max(1, n));
782
+ return Math.max(1, Math.min(ENTRY_EXIT_CHARS, Math.max(half, Math.ceil(Math.max(1, n) / 4))));
783
+ }
752
784
 
753
785
  /**
754
786
  * The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
@@ -756,9 +788,9 @@ function lightMargin(n) {
756
788
  * `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only `count * stepMs`
757
789
  * of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
758
790
  */
759
- export function crossMs(count) {
791
+ export function crossMs(count, stepMs = STEP_MS) {
760
792
  const cells = Math.max(1, count) + 2 * lightMargin(count);
761
- return cells * STEP_MS;
793
+ return cells * stepMs;
762
794
  }
763
795
 
764
796
  /**
@@ -771,7 +803,7 @@ export function crossMs(count) {
771
803
  * Claude Code can use zero because its entry run is ten cells: the light is already off the row for a
772
804
  * second before it comes back. Ours enters from three cells away, too short to cover the wrap.
773
805
  */
774
- export const REST_MS = 250;
806
+ export const REST_MS = 300;
775
807
 
776
808
  /**
777
809
  * The full cycle for a row: one crossing, then the rest.
@@ -779,8 +811,8 @@ export const REST_MS = 250;
779
811
  * `count` enters into it: a longer row has more characters to light, so its cycle is longer. It used to be
780
812
  * constant, because a fixed-time crossing spread itself over however many cells there happened to be.
781
813
  */
782
- export function cycleMs(count) {
783
- return crossMs(count) + REST_MS;
814
+ export function cycleMs(count, stepMs = STEP_MS) {
815
+ return crossMs(count, stepMs) + REST_MS;
784
816
  }
785
817
 
786
818
  /**
@@ -861,7 +893,7 @@ function lightOrigin(key, anchor, now) {
861
893
  * 1.9 seconds for `Working...`. Taking milliseconds decouples the two: the caller may redraw as often
862
894
  * as it likes, and the light's speed is stated directly rather than implied by a frame count.
863
895
  */
864
- export function lightAt(phaseMs, index, count, edge = 'cosine') {
896
+ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
865
897
  const n = Math.max(1, count);
866
898
  // Two widths, and the distinction is the shape of the light: `half` is the OUTER edge (core plus the
867
899
  // fade either side) and `core` is the part that stays at full brightness.
@@ -886,9 +918,9 @@ export function lightAt(phaseMs, index, count, edge = 'cosine') {
886
918
  // The margin is `max(10, half)`: ten is the target, but the light must clear its own width or it would
887
919
  // still be lighting the last characters when the round ended — the failure the half-width version was
888
920
  // written to fix, and which a flat ten reintroduces on a wide band.
889
- const margin = Math.max(ENTRY_EXIT_CHARS, half);
890
- const travel = n + 2 * margin;
891
- const steps = Math.floor((phaseMs / crossMs(n)) * travel);
921
+ const margin = lightMargin(n);
922
+ const travel = n + 2 * margin;
923
+ const steps = Math.floor((phaseMs / crossMs(n, stepMs)) * travel);
892
924
  const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
893
925
  // `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
894
926
  // where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
@@ -916,13 +948,13 @@ export function lightAt(phaseMs, index, count, edge = 'cosine') {
916
948
  * @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
917
949
  * @returns {string} ANSI, ready to splice into a row
918
950
  */
919
- function shimmerText(text, phaseMs, base, glint, edge = 'cosine') {
951
+ function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
920
952
  const s = String(text == null ? '' : text);
921
953
  const n = s.length;
922
954
  if (!n) return '';
923
955
  const out = [];
924
956
  for (let i = 0; i < n; i++) {
925
- const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, true)));
957
+ const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
926
958
  const r = Math.round(base[0] + (glint[0] - base[0]) * t);
927
959
  const g = Math.round(base[1] + (glint[1] - base[1]) * t);
928
960
  const b = Math.round(base[2] + (glint[2] - base[2]) * t);
@@ -1531,40 +1563,58 @@ export function lineDiff(oldStr, newStr, startLine = 1) {
1531
1563
 
1532
1564
  // Word-wrap a string that may contain ANSI colour codes, measuring VISIBLE
1533
1565
  // width only. Escape sequences are carried over to the line they belong to.
1534
- function wrapAnsiWords(str, width) {
1535
- width = Math.max(1, width | 0);
1536
- const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
1537
- const out = [];
1538
- let line = '';
1539
- let lineW = 0;
1540
- const push = () => { if (line !== '') out.push(line); line = ''; lineW = 0; };
1541
- for (const tok of tokens) {
1542
- if (tok === '') continue;
1543
- const tw = visualCol(tok);
1544
- if (/^\s+$/.test(tok)) {
1545
- if (lineW > 0) { line += tok; lineW += tw; }
1546
- continue;
1547
- }
1548
- if (lineW > 0 && lineW + tw > width) push();
1549
- if (tw > width && visualCol(tok) > width) {
1550
- // Hard-split an over-long token by visible columns.
1551
- let chunk = '';
1552
- let cw = 0;
1553
- for (const ch of tok) {
1554
- const chw = visualCol(ch);
1555
- if (cw + chw > width) { out.push(chunk); chunk = ''; cw = 0; }
1556
- chunk += ch; cw += chw;
1557
- }
1558
- if (chunk) { line = chunk; lineW = cw; }
1559
- continue;
1560
- }
1561
- line += tok;
1562
- lineW += tw;
1563
- }
1564
- if (line !== '') out.push(line);
1565
- if (out.length === 0) out.push('');
1566
- return out;
1567
- }
1566
+ function wrapAnsiWords(str, width) {
1567
+ width = Math.max(1, width | 0);
1568
+ const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
1569
+ const out = [];
1570
+ let line = '';
1571
+ let lineW = 0;
1572
+ // THE COLOUR ACTIVE AT THE BREAK, carried onto the next row.
1573
+ //
1574
+ // A colour is opened once and closed at the end of the string, so a line wrapped mid-colour leaves the
1575
+ // continuation starting with plain text — the terminal paints it in its DEFAULT foreground, which on a
1576
+ // dark theme is brighter than the grey the rest of the block uses. That is what a wrapped, expanded
1577
+ // command looked like: the first row grey and every row below it lighter.
1578
+ //
1579
+ // Tracked from the escapes seen so far and re-emitted at the start of each new row, then cleared. `0m`
1580
+ // resets it, so a line that closes its colour before the break does not reopen one.
1581
+ let active = '';
1582
+ const scan = (text) => {
1583
+ for (const m of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
1584
+ active = m[1] === '0' || m[1] === '' ? '' : `\x1b[${m[1]}m`;
1585
+ }
1586
+ };
1587
+ const push = () => { if (line !== '') out.push(line); line = active; lineW = 0; };
1588
+ for (const tok of tokens) {
1589
+ if (tok === '') continue;
1590
+ const tw = visualCol(tok);
1591
+ if (/^\s+$/.test(tok)) {
1592
+ if (lineW > 0) { line += tok; lineW += tw; }
1593
+ continue;
1594
+ }
1595
+ if (lineW > 0 && lineW + tw > width) push();
1596
+ scan(tok);
1597
+ if (tw > width && visualCol(tok) > width) {
1598
+ // Hard-split an over-long token by visible columns.
1599
+ let chunk = '';
1600
+ let cw = 0;
1601
+ for (const ch of tok) {
1602
+ const chw = visualCol(ch);
1603
+ if (cw + chw > width) { out.push(chunk); chunk = active; cw = 0; }
1604
+ chunk += ch; cw += chw;
1605
+ }
1606
+ if (chunk) { line = chunk; lineW = cw; }
1607
+ continue;
1608
+ }
1609
+ line += tok;
1610
+ lineW += tw;
1611
+ }
1612
+ if (line !== '') out.push(line);
1613
+ if (out.length === 0) out.push('');
1614
+ // Rows that were seeded with a carried colour but ended up holding nothing are dropped: they would
1615
+ // render as an invisible escape-only line.
1616
+ return out.map((s) => s.replace(/^\x1b\[[0-9;]*m$/, '')).filter((s) => s !== '' || out.length === 1);
1617
+ }
1568
1618
 
1569
1619
  // Word-aware wrap that also returns each row's starting offset in the source
1570
1620
  // string, so the caret can be mapped to a (row, col) inside the composer.
@@ -2075,7 +2125,7 @@ function keyArgument(name, args, workspace, expanded = false) {
2075
2125
  }
2076
2126
  return '';
2077
2127
  }
2078
- export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false) {
2128
+ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false) {
2079
2129
  const name = msg.toolName || '';
2080
2130
  const arg = keyArgument(name, msg.toolArgs, workspace, expanded);
2081
2131
  const verb = msg.pending ? 'Using' : 'Used';
@@ -2119,16 +2169,21 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2119
2169
  // `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time to
2120
2170
  // zero so the light never moved at all.
2121
2171
  const anchorSpin = pulseStart ?? spin;
2122
- // THE KEY MUST NOT REPEAT. `pulseStart` is a tick count within a turn, so two different rows can
2123
- // reach the same value; when that happened, the second row found the first one's origin still in the
2124
- // map and inherited an elapsed time far past its own start, snapping the light to a position mid-row.
2125
- //
2126
- // The message's own tool-call id is unique per row and stable across frames, which is exactly what a
2127
- // key needs. `anchorSpin` stays in it so the same message re-animated on a new tick gets a new origin.
2128
- const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}:${anchorSpin}` : `tool:${anchorSpin}`;
2172
+ // THE KEY IS THE ROW'S OWN IDENTITY, and nothing else.
2173
+ //
2174
+ // It used to carry `anchorSpin` as well, on the reasoning that the same message re-animated on a new
2175
+ // tick should get a new origin. The opposite happened: `anchorSpin` is `pulseStart ?? spin`, and when
2176
+ // the caller passes no `pulseStart` it IS the tick counter — it changes every 80ms, so every frame
2177
+ // built a new key, `lightOrigin` created a fresh origin for it, and `elapsedMs` stayed at 0. The light
2178
+ // sat still, except for the one frame after the row content changed and the message object was
2179
+ // replaced.
2180
+ //
2181
+ // A row animates from the moment it appears until it stops being pending, and its id is stable across
2182
+ // that whole life: one origin, and elapsed grows from there.
2183
+ const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}` : `tool:${name}`;
2129
2184
  const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
2130
2185
  // ONE shimmer for both rows — see `shimmerText`.
2131
- const phaseMs = elapsedMs % cycleMs(name.length);
2186
+ const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
2132
2187
 
2133
2188
  // The same restraint as the Working row's glint: the light is the base colour with a small lift in
2134
2189
  // the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
@@ -2136,7 +2191,12 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2136
2191
  const BASE = [0, 215, 255]; // cyan: the name's resting colour
2137
2192
  const GLINT = [110, 240, 255]; // the same hue, a shade brighter
2138
2193
 
2139
- nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine');
2194
+ // The same setting the Working row honours: a pending tool's name is decoration too.
2195
+ if (reducedMotion) {
2196
+ nameDisplay = col(name, C.cyan);
2197
+ } else {
2198
+ nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS);
2199
+ }
2140
2200
  } else {
2141
2201
  // Non-pending: show in plain cyan
2142
2202
  nameDisplay = col(name, C.cyan);
@@ -2384,7 +2444,7 @@ export function todoPanelHeight(state) {
2384
2444
  // wrapping, no ANSI interpretation. It exists to answer "what did the model
2385
2445
  // actually send" when a rendering bug makes a reply look wrong — with the renderer
2386
2446
  // in the way there is no way to tell a bad reply from a bad render.
2387
- function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode, shimmerEdge) {
2447
+ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode, shimmerEdge, reducedMotion = false) {
2388
2448
  if (rawMode && (msg.role === 'assistant' || msg.role === 'thinking')) {
2389
2449
  const src = String(msg.text == null ? '' : msg.text);
2390
2450
  // escape the ANSI bytes so a stray `\x1b[31m` in the model's output cannot
@@ -2397,7 +2457,7 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2397
2457
  // Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
2398
2458
  // The body already carries its own ANSI colours, so mark it pre-colored.
2399
2459
  if (msg.role === 'tool') {
2400
- const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded);
2460
+ const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion);
2401
2461
  // Red bullet for a failed call (a Bash non-zero exit / [error: …]).
2402
2462
  const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
2403
2463
  const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
@@ -3596,12 +3656,12 @@ export function renderChatLines(state, w, viewport) {
3596
3656
  const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
3597
3657
  const rowWidth = border ? boxText : innerW;
3598
3658
  const cached = m._cache;
3599
- // Which rows animate? A pending tool call and a running compaction block both
3600
- // derive their look from `state.spin`, so the cached layout must be invalidated
3601
- // when that tick advances. Setting spinKey to 0 for everything else keeps
3602
- // static rows cached across ticks.
3603
- const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
3604
- const spinKeyNow = animating ? (state.spin || 0) : 0;
3659
+ // Which rows animate? A pending tool call and a running compaction block do, and their appearance
3660
+ // depends on the current step of the light. Everything else gets a constant key, so a static row is
3661
+ // laid out once and reused across frames.
3662
+ // A pending tool advances from the wall clock at TOOL_STEP_MS, so its cache key is THAT step index.
3663
+ const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
3664
+ const spinKeyNow = animating ? Math.floor(Date.now() / TOOL_STEP_MS) : 0;
3605
3665
  const valid = cached
3606
3666
  && cached.rowW === rowWidth && cached.expanded === expanded
3607
3667
  && cached.textRef === m.text && cached.streamRef === m.streamContent
@@ -3655,7 +3715,8 @@ export function renderChatLines(state, w, viewport) {
3655
3715
  // happen (replacing a word). Those are caught by the `_cache` nulling at the
3656
3716
  // edit sites, which is what the shared-store path below relies on too.
3657
3717
  const countLen = typeof m.text === 'string' ? m.text.length : 0;
3658
- if (!valid && !cached && m._count && m._count.key === countKey && m._count.textLen === countLen) {
3718
+ // Only a NON-ANIMATING message can be answered from its row count. See the note below.
3719
+ if (!valid && !cached && !animating && m._count && m._count.key === countKey && m._count.textLen === countLen) {
3659
3720
  msgLen[mi] = m._count.len + rules;
3660
3721
  cursor += msgLen[mi];
3661
3722
  prevBordered = border;
@@ -3685,7 +3746,8 @@ export function renderChatLines(state, w, viewport) {
3685
3746
  msgLen[mi] = shared.rows.length + rules;
3686
3747
  } else {
3687
3748
  const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3688
- state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
3749
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
3750
+ reducedMotionOn(state));
3689
3751
  const wrapped = rows.map(rowToLine);
3690
3752
  const isPending2 = m.pending ? 1 : 0;
3691
3753
  const cc = {
@@ -3907,6 +3969,10 @@ export function metricsSignature(state, n, w, expanded, innerW) {
3907
3969
  mix(m.pluginLog && m.expanded ? 1 : 0);
3908
3970
  mix(m._cache ? 1 : 0);
3909
3971
  mix(m._count ? 1 : 0);
3972
+ // A pending row repaints as the light moves, with none of the fields above changing. Without this
3973
+ // the signature matched every frame and the transcript was never re-walked, so the animation froze
3974
+ // until the message itself was touched.
3975
+ if (m.pending) mix(Math.floor(Date.now() / STEP_MS));
3910
3976
  }
3911
3977
  return h;
3912
3978
  }
@@ -6852,6 +6918,8 @@ export function makeState({ cfg, session, opts }) {
6852
6918
  // was re-picked (setEffort is the only other writer, and it sets both).
6853
6919
  const effort = (session && session.effort) || cfg.effort || (cfg.reasoning ? 'on' : 'off');
6854
6920
  return {
6921
+ // No model chosen yet: the interface says so instead of refusing to start.
6922
+ modelMissing: !!(opts && opts.modelMissing),
6855
6923
  workspace: cwd,
6856
6924
  cfg,
6857
6925
  // The SAME session local this function already reads for `effort`, kept on the state
@@ -11964,24 +12032,45 @@ function registerProvider(state, cfg, h, name, baseUrl, apiKey, protocol, typeLa
11964
12032
  fetchAndRegisterModels(state, cfg, name, h);
11965
12033
  }
11966
12034
 
11967
- async function fetchAndRegisterModels(state, cfg, providerName, h) {
11968
- const p = (cfg.raw.providers || {})[providerName] || {};
11969
- const baseUrl = p.base_url || p.baseUrl || '';
11970
- if (!baseUrl) return;
11971
- h.notice(`Discovering models for ${providerName}…`, 'info');
11972
- const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
11973
- if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
11974
- cfg.raw.models = cfg.raw.models || {};
11975
- for (const m of models) {
11976
- addModel(providerName, m.id, { display_name: m.display, contextLength: m.contextLength, maxTokens: m.maxTokens });
11977
- // Merge over any existing entry rather than replacing it: discovery reports
11978
- // fewer fields than the user may have set (context_length, pricing, …), and a
11979
- // field the endpoint did not report must not blank out a value already there.
11980
- const key = modelKey(providerName, m.id);
11981
- cfg.raw.models[key] = mergeRefreshedModelEntry(providerName, m, cfg.raw.models[key]);
11982
- }
11983
- h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
11984
- }
12035
+ async function fetchAndRegisterModels(state, cfg, providerName, h) {
12036
+ const p = (cfg.raw.providers || {})[providerName] || {};
12037
+ const baseUrl = p.base_url || p.baseUrl || '';
12038
+ if (!baseUrl) return;
12039
+ h.notice(`Discovering models for ${providerName}…`, 'info');
12040
+ const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
12041
+ // THE FETCH MUST SUCCEED BEFORE ANYTHING IS REMOVED.
12042
+ //
12043
+ // Deleting first and fetching second means an unreachable endpoint leaves the provider with no models at
12044
+ // all. Everything below runs only after a non-empty result.
12045
+ if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
12046
+
12047
+ cfg.raw.models = cfg.raw.models || {};
12048
+ // Snapshot what this provider had, so the merge below can keep fields the source does not report.
12049
+ const prevByKey = new Map();
12050
+ for (const key of Object.keys(cfg.raw.models)) {
12051
+ if (key.split('/')[0] !== providerName) continue;
12052
+ prevByKey.set(key, cfg.raw.models[key]);
12053
+ delete cfg.raw.models[key];
12054
+ }
12055
+
12056
+ // REPLACE THE LIST WITH WHAT WAS JUST FETCHED.
12057
+ //
12058
+ // A model the endpoint no longer reports disappears, and a renamed one does not appear twice. Merging
12059
+ // over the old entries kept both for ever, which is what made Alt+R look like it did nothing.
12060
+ const kept = [];
12061
+ for (const m of models) {
12062
+ const key = modelKey(providerName, m.id);
12063
+ // Merge over the SNAPSHOT: the live entries were just deleted, so reading them yields undefined and
12064
+ // the merge folds nothing — every field the source omits (context_length, pricing) would be wiped.
12065
+ const entry = mergeRefreshedModelEntry(providerName, m, prevByKey.get(key));
12066
+ cfg.raw.models[key] = entry;
12067
+ kept.push({ ...entry, id: m.id });
12068
+ }
12069
+ // Rewrite the provider's TOML blocks from that list. `addModel` appends one block per model and returns
12070
+ // early when one already exists, so calling it here left the removed entries on disk.
12071
+ try { replaceProviderModels(providerName, kept); } catch { /* the in-memory list is still correct */ }
12072
+ h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
12073
+ }
11985
12074
 
11986
12075
  // Merge ONE refreshed model's data over its previous config entry.
11987
12076
  //
@@ -12298,10 +12387,17 @@ export function tokenize(str) {
12298
12387
  if (f === undefined) break;
12299
12388
  out.push({ key: f === 'P' ? 'f1' : 'escape' });
12300
12389
  i += 3;
12301
- } else {
12302
- out.push({ key: 'escape' });
12303
- i++;
12304
- }
12390
+ } else {
12391
+ // `ESC <printable>` is Alt+that key. A second ESC stays a plain Escape.
12392
+ const altNext = str[i + 1];
12393
+ if (altNext && altNext >= '!' && altNext <= '~') {
12394
+ out.push({ key: 'alt-' + altNext.toLowerCase() });
12395
+ i += 2;
12396
+ continue;
12397
+ }
12398
+ out.push({ key: 'escape' });
12399
+ i++;
12400
+ }
12305
12401
  } else if (c === '\r') {
12306
12402
  out.push({ key: 'enter' });
12307
12403
  i++;
@@ -12941,7 +13037,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12941
13037
  if (e.type === 'data') {
12942
13038
  // A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
12943
13039
  // because the paint path cannot tell "slow" from "quiet" — only the events can.
12944
- state.lastTokenAt = Date.now();
13040
+ state.lastTokenAt = Date.now();
12945
13041
  // Feed the answer text into the paced buffer instead of painting it. The
12946
13042
  // buffer closes any open reasoning region itself, in arrival order, so the
12947
13043
  // block settles exactly when the provider moved on rather than when the next
@@ -13007,21 +13103,20 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13007
13103
  // - on a cache hit some providers report only the uncached remainder, which made the gauge
13008
13104
  // COLLAPSE mid-turn and then jump back.
13009
13105
  //
13010
- // The estimate has its own correction for that: `calibrate` in agent.js learns the ratio from
13011
- // these very reports and applies it where the estimate is produced, so the gauge stays ONE
13012
- // coherent number while still converging on the truth.
13013
- //
13014
- // The turn's OUTPUT tokens are still taken from here — that figure has no other source.
13015
- // The TURN's output tokens, accumulated exactly from the provider rather than estimated from
13016
- // streamed characters. Kept separate from the context figure above: one counts what was sent,
13017
- // the other what came back.
13018
- if (e.usage && Number.isFinite(e.usage.output)) {
13019
- state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
13020
- state.turnOutputTokens = Math.max(
13021
- state.turnOutputTokens || 0,
13022
- (state.turnOutputSettled || 0) + (state.turnOutputEstimate || 0),
13023
- );
13024
- }
13106
+ // PROVIDER REPORT ARRIVES -> the estimate it covers is done.
13107
+ //
13108
+ // The counter is `settled + estimate`: finished steps plus the one streaming now. When a usage
13109
+ // report lands, `settled` grows by that step's exact figure AND `estimate` still holds the
13110
+ // same step's guess, so the row showed roughly double — measured 25 estimated + 25 settled
13111
+ // reading 50 — and every later step built on the inflated total.
13112
+ //
13113
+ // Clearing `estimate` here is what makes the estimate a LIVE figure for the step in flight
13114
+ // rather than a running sum that never forgets.
13115
+ if (e.usage && Number.isFinite(e.usage.output)) {
13116
+ state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
13117
+ state.turnOutputEstimate = 0;
13118
+ state.turnOutputTokens = state.turnOutputSettled;
13119
+ }
13025
13120
  refreshCost(state, cfg);
13026
13121
  viewCur.renderSoon(); return;
13027
13122
  }
@@ -13099,6 +13194,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13099
13194
  // Track the key argument as it streams so "Using Bash (cmd)" shows
13100
13195
  // the command DURING the run rather than only once it has finished.
13101
13196
  entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
13197
+ entry._cache = null; // the argument is part of the rendered line
13102
13198
  if (entry.toolName === 'Write') {
13103
13199
  entry.streamContent = argStreamValue(entry._argStream, 'content');
13104
13200
  // Incremental line count so the preview's "… N more lines" note is
@@ -13634,16 +13730,20 @@ export async function startTUI(opts) {
13634
13730
  }
13635
13731
 
13636
13732
 
13733
+ // Set when the config has no model, so the interface can say so without blocking startup.
13734
+ let modelMissing = false;
13735
+
13637
13736
  // FIRST: Ensure model is initialized before restoring session
13638
13737
  if (!cfg.model || !cfg.innerModel) {
13639
13738
  // Model not set yet - use default or prompt user
13640
13739
  if (!cfg.model) {
13641
- console.log('No model configured. Please run /model to select one.');
13642
- return 1;
13740
+ // NOT a return: a fresh install has no model yet, and the advice is `/model` — which needs the
13741
+ // prompt this function is about to build. Flagged instead, and reported once the TUI is up.
13742
+ modelMissing = true;
13643
13743
  }
13644
13744
  }
13645
13745
 
13646
- const state = makeState({ cfg, session, opts });
13746
+ const state = makeState({ cfg, session, opts: { ...opts, modelMissing } });
13647
13747
  // Mirror the session on the state so `/move` can rebind ONE binding and have every
13648
13748
  // consumer follow (see `replaceSession`).
13649
13749
  state.session = session;
@@ -14159,6 +14259,7 @@ export async function startTUI(opts) {
14159
14259
  }
14160
14260
  let lastFrame = null;
14161
14261
  let paintScheduled = false;
14262
+ let lastPaintAt = 0; // when the last frame actually went to the terminal
14162
14263
  // Deadline for the next FULL repaint. A differential painter compares against the frame
14163
14264
  // it BELIEVES it painted, never the real screen, so any divergence between the two is
14164
14265
  // permanent: it survives every later frame because the comparison keeps saying "that row
@@ -14193,6 +14294,7 @@ export async function startTUI(opts) {
14193
14294
  state._hitboxes = frame.hitboxes || [];
14194
14295
  state._composerMeta = frame.composerMeta || [];
14195
14296
  if (out) stdout.write(out);
14297
+ lastPaintAt = now;
14196
14298
  // "Painted" becomes observable here: everything that reaches the screen goes
14197
14299
  // through this one function, so the tick advancing means bytes left this process.
14198
14300
  // See src/flush-tick.js for what reads it.
@@ -14239,11 +14341,26 @@ export async function startTUI(opts) {
14239
14341
  */
14240
14342
  function countOutputTokens(n) { return sessHost.countOutputTokens(n); }
14241
14343
 
14242
- function renderSoon() {
14243
- if (paintScheduled) return;
14244
- paintScheduled = true;
14245
- setImmediate(paintNow);
14246
- }
14344
+ /**
14345
+ * The render rate this session is capped at, from `/settings` → Render FPS.
14346
+ *
14347
+ * Read on every call rather than cached: the setting can change mid-session, and a cached rate would
14348
+ * only take effect after a restart.
14349
+ */
14350
+ function renderFps() {
14351
+ const v = Number(cfg && cfg.render_fps);
14352
+ // A terminal cannot render at 0Hz; the lowest meaningful rate is one frame a second.
14353
+ return Number.isFinite(v) ? Math.max(1, Math.round(v)) : 30;
14354
+ }
14355
+
14356
+ function renderSoon() {
14357
+ if (paintScheduled) return;
14358
+ const budget = Math.max(1, Math.round(1000 / renderFps()));
14359
+ const wait = Math.max(0, (lastPaintAt || 0) + budget - Date.now());
14360
+ paintScheduled = true;
14361
+ if (wait === 0) { setImmediate(paintNow); return; }
14362
+ setTimeout(paintNow, wait);
14363
+ }
14247
14364
  function openPicker(p) {
14248
14365
  let footer = p.footer ? { focused: false, ...p.footer } : null;
14249
14366
  if (p.footerFor) {
@@ -15353,7 +15470,7 @@ export async function startTUI(opts) {
15353
15470
  ],
15354
15471
  resolve,
15355
15472
  };
15356
- viewCur.render();
15473
+ renderSoon();
15357
15474
  }),
15358
15475
  }))
15359
15476
  .then((res) => {
@@ -16588,7 +16705,7 @@ export async function startTUI(opts) {
16588
16705
  // swallowing them bought nothing and cost the one thing a long plan needs: a way to
16589
16706
  // read past the fold. Everything else still belongs to the review.
16590
16707
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
16591
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
16708
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
16592
16709
  renderFrame();
16593
16710
  return;
16594
16711
  }
@@ -16811,7 +16928,7 @@ export async function startTUI(opts) {
16811
16928
  // answer, so "own ALL input" does not need them — and without this a long question
16812
16929
  // with an option list taller than the screen could not be read past at all.
16813
16930
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
16814
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
16931
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
16815
16932
  renderFrame();
16816
16933
  return;
16817
16934
  }
@@ -16833,7 +16950,7 @@ export async function startTUI(opts) {
16833
16950
  // its own arrow behaviour on the un-modified keys.
16834
16951
  if (t.key === 'c-up' || t.key === 'c-down') {
16835
16952
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16836
- scrollChat(state, t.key === 'c-up' ? 3 : -3);
16953
+ scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
16837
16954
  renderFrame();
16838
16955
  return;
16839
16956
  }
@@ -16842,6 +16959,21 @@ export async function startTUI(opts) {
16842
16959
  // Ctrl+L — expand / collapse the plugin log block(s). The last one is the
16843
16960
  // useful target (it is the most recent), but every pluginLog message is toggled
16844
16961
  // so a session with several reloads behaves predictably.
16962
+ if (t.key === 'alt-r') {
16963
+ // Only when nothing owns the keyboard: a picker or a dialog has its own meaning for R.
16964
+ if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16965
+ const current = (cfg && cfg.provider) || '';
16966
+ if (!current) {
16967
+ notice('No provider selected — use /model to pick one.', 'error');
16968
+ } else {
16969
+ // Fire and forget: the notice reports the outcome, and blocking the key handler on a network
16970
+ // round-trip would freeze input for as long as the endpoint takes.
16971
+ void fetchAndRegisterModels(state, cfg, current, { notice: (m, k) => notice(m, k) });
16972
+ }
16973
+ renderFrame();
16974
+ return;
16975
+ }
16976
+ }
16845
16977
  if (t.key === 'c-l') {
16846
16978
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16847
16979
  const logs = (state.chat || []).filter((m) => m && m.pluginLog);
@@ -17084,7 +17216,7 @@ export async function startTUI(opts) {
17084
17216
  renderFrame(); return;
17085
17217
  }
17086
17218
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
17087
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
17219
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
17088
17220
  renderFrame(); return;
17089
17221
  }
17090
17222
  if (t.key === 'up' || t.key === 'down') {
@@ -17565,12 +17697,13 @@ export async function startTUI(opts) {
17565
17697
  return;
17566
17698
  }
17567
17699
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
17568
- // With the pointer ON the scrollbar, scroll one line per notch: the bar is
17569
- // the precision control, and 3-line steps there feel jumpy on a long
17570
- // transcript. Everywhere else keeps the usual 3-line step.
17571
- const onBar = !!(state._lastMouse && isOnScrollbar(state._lastMouse));
17572
- const step = onBar ? 1 : 3;
17573
- const d = t.key === 'wheelup' ? step : -step;
17700
+ // HOW HARD THE USER IS TURNING, from the interval between key events.
17701
+ //
17702
+ // One rate cannot serve both gestures: a fixed one row makes a fast spin crawl, a fixed three jumps
17703
+ // past what a careful reader wanted. The interval between events is the speed, and it is already
17704
+ // in the stream — no timer, no separate gesture detection. Slow: one row. Fast: up to eight.
17705
+ const step = wheelStep(state, "wheel");
17706
+ const d = t.key === 'wheelup' ? step : -step;
17574
17707
  if (state.panel) {
17575
17708
  const p = state.panel;
17576
17709
  p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
@@ -17934,6 +18067,26 @@ export async function startTUI(opts) {
17934
18067
  // Read live, so a /external flip applies to the very next tool call.
17935
18068
  function externalAllowed() { return sessHost.externalAllowed(); }
17936
18069
 
18070
+ /**
18071
+ * How many rows one notch or keypress should move, from how fast they are arriving.
18072
+ *
18073
+ * Shared by the wheel and by Ctrl+Up/Down so the two cannot drift: they are the same gesture with
18074
+ * different hardware. The EVENT STREAM is the speed sensor — a wheel turned slowly sends notches far
18075
+ * apart and one spun sends them in a burst, and a key held down repeats at the OS repeat rate.
18076
+ *
18077
+ * `family` keeps the two clocks apart: alternating a wheel notch with a Ctrl+Up should not be read as one
18078
+ * burst of either. It also makes the FIRST event of a family one row — `Infinity` lands in the slowest
18079
+ * step, so nothing jumps on the first touch after a pause.
18080
+ */
18081
+ function wheelStep(state, family) {
18082
+ const now = Date.now();
18083
+ const at = state._stepAt || (state._stepAt = {});
18084
+ const since = at[family] ? now - at[family] : Infinity;
18085
+ at[family] = now;
18086
+ // 140ms is about the gap between deliberate notches; under 55ms they are arriving in a burst.
18087
+ return since > 140 ? 1 : since > 90 ? 2 : since > 55 ? 4 : 8;
18088
+ }
18089
+
17937
18090
  function scrollChat(state, delta) {
17938
18091
  const cols = dims().cols, rows = dims().rows;
17939
18092
  // Only the TOTAL row count is needed to clamp the scroll position. This used to
@@ -18187,7 +18340,48 @@ export async function startTUI(opts) {
18187
18340
  // calls, which is what keeps the two front-ends behaviourally identical.
18188
18341
  // Straight through to the core. It validates and throws in the same shape as before, so
18189
18342
  // the HTTP layer (which maps a throw to 409) is unaffected.
18190
- hub.actions.submit = (text) => core.submit(text);
18343
+ // `opts.new` starts a FRESH session before the prompt runs.
18344
+ //
18345
+ // The caller does not know the new session's id in advance, so asking them to create one first
18346
+ // means two requests and a round trip to learn an id that this side invents anyway. Passing
18347
+ // `new: true` lets the daemon accept a prompt against any session URL and have it run in a new
18348
+ // session instead — the same thing `/new` does, minus the slash command.
18349
+ hub.actions.submit = async (text, opts) => {
18350
+ if (opts && opts.new) {
18351
+ // Flush the outgoing session first: its todos and transcript belong to the id being replaced,
18352
+ // and `dispatch` does exactly what `/new` does, so the two cannot drift apart.
18353
+ await core.dispatch('new', '');
18354
+ // The hub's rows and event ring belong to the session that just ended. Clearing them keeps a
18355
+ // viewer from showing the previous conversation under the new id.
18356
+ hub.rows = [];
18357
+ hub.events = [];
18358
+ }
18359
+ // `core.submit` AWAITS THE WHOLE TURN, so its result is the finished turn — no polling needed.
18360
+ const out = await core.submit(text);
18361
+ // `opts.return` asks for the transcript of what just ran:
18362
+ //
18363
+ // "turn" the rows of this turn, tool results included
18364
+ // "full" every row the session holds
18365
+ // "none" (default) whatever `core.submit` returned, unchanged
18366
+ //
18367
+ // Without this a caller had to submit, wait an unknown time, then issue a second request for
18368
+ // `/api/state` — two round trips and a guess at how long the turn would take.
18369
+ const want = opts && opts.return;
18370
+ if (!want || want === 'none') return out;
18371
+ // FORCE ONE SYNC: `syncChat` does nothing while no browser is subscribed, so `hub.rows` would be
18372
+ // empty here and the caller would get an empty transcript from a turn that plainly ran.
18373
+ try { hub.syncChat(state.chat || []); } catch { /* the reply still carries `result` */ }
18374
+ // The turn begins at the last user row; everything after it is what the prompt produced.
18375
+ const rows = Array.isArray(hub.rows) ? hub.rows : [];
18376
+ let from = 0;
18377
+ for (let k = rows.length - 1; k >= 0; k--) { if (rows[k] && rows[k].role === 'user') { from = k; break; } }
18378
+ const slice = want === 'full' ? rows : rows.slice(from);
18379
+ const detail = (opts && opts.detail) || 'full';
18380
+ const shaped = detail === 'minimal' ? slice.filter((r) => r.role !== 'tool')
18381
+ : detail === 'summary' ? slice.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r))
18382
+ : slice;
18383
+ return { result: out, rows: shaped, detail, sessionId: session && session.id };
18384
+ };
18191
18385
  hub.actions.dispatch = async (cmd, arg) => {
18192
18386
  const out = await core.dispatch(cmd, arg);
18193
18387
  // The core runs the command; the repaint is this front end's business, so it stays