@hncode/hncode 0.5.4 → 0.5.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
@@ -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
  }
@@ -11964,24 +12030,45 @@ function registerProvider(state, cfg, h, name, baseUrl, apiKey, protocol, typeLa
11964
12030
  fetchAndRegisterModels(state, cfg, name, h);
11965
12031
  }
11966
12032
 
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
- }
12033
+ async function fetchAndRegisterModels(state, cfg, providerName, h) {
12034
+ const p = (cfg.raw.providers || {})[providerName] || {};
12035
+ const baseUrl = p.base_url || p.baseUrl || '';
12036
+ if (!baseUrl) return;
12037
+ h.notice(`Discovering models for ${providerName}…`, 'info');
12038
+ const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
12039
+ // THE FETCH MUST SUCCEED BEFORE ANYTHING IS REMOVED.
12040
+ //
12041
+ // Deleting first and fetching second means an unreachable endpoint leaves the provider with no models at
12042
+ // all. Everything below runs only after a non-empty result.
12043
+ if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
12044
+
12045
+ cfg.raw.models = cfg.raw.models || {};
12046
+ // Snapshot what this provider had, so the merge below can keep fields the source does not report.
12047
+ const prevByKey = new Map();
12048
+ for (const key of Object.keys(cfg.raw.models)) {
12049
+ if (key.split('/')[0] !== providerName) continue;
12050
+ prevByKey.set(key, cfg.raw.models[key]);
12051
+ delete cfg.raw.models[key];
12052
+ }
12053
+
12054
+ // REPLACE THE LIST WITH WHAT WAS JUST FETCHED.
12055
+ //
12056
+ // A model the endpoint no longer reports disappears, and a renamed one does not appear twice. Merging
12057
+ // over the old entries kept both for ever, which is what made Alt+R look like it did nothing.
12058
+ const kept = [];
12059
+ for (const m of models) {
12060
+ const key = modelKey(providerName, m.id);
12061
+ // Merge over the SNAPSHOT: the live entries were just deleted, so reading them yields undefined and
12062
+ // the merge folds nothing — every field the source omits (context_length, pricing) would be wiped.
12063
+ const entry = mergeRefreshedModelEntry(providerName, m, prevByKey.get(key));
12064
+ cfg.raw.models[key] = entry;
12065
+ kept.push({ ...entry, id: m.id });
12066
+ }
12067
+ // Rewrite the provider's TOML blocks from that list. `addModel` appends one block per model and returns
12068
+ // early when one already exists, so calling it here left the removed entries on disk.
12069
+ try { replaceProviderModels(providerName, kept); } catch { /* the in-memory list is still correct */ }
12070
+ h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
12071
+ }
11985
12072
 
11986
12073
  // Merge ONE refreshed model's data over its previous config entry.
11987
12074
  //
@@ -12298,10 +12385,17 @@ export function tokenize(str) {
12298
12385
  if (f === undefined) break;
12299
12386
  out.push({ key: f === 'P' ? 'f1' : 'escape' });
12300
12387
  i += 3;
12301
- } else {
12302
- out.push({ key: 'escape' });
12303
- i++;
12304
- }
12388
+ } else {
12389
+ // `ESC <printable>` is Alt+that key. A second ESC stays a plain Escape.
12390
+ const altNext = str[i + 1];
12391
+ if (altNext && altNext >= '!' && altNext <= '~') {
12392
+ out.push({ key: 'alt-' + altNext.toLowerCase() });
12393
+ i += 2;
12394
+ continue;
12395
+ }
12396
+ out.push({ key: 'escape' });
12397
+ i++;
12398
+ }
12305
12399
  } else if (c === '\r') {
12306
12400
  out.push({ key: 'enter' });
12307
12401
  i++;
@@ -12941,7 +13035,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12941
13035
  if (e.type === 'data') {
12942
13036
  // A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
12943
13037
  // because the paint path cannot tell "slow" from "quiet" — only the events can.
12944
- state.lastTokenAt = Date.now();
13038
+ state.lastTokenAt = Date.now();
12945
13039
  // Feed the answer text into the paced buffer instead of painting it. The
12946
13040
  // buffer closes any open reasoning region itself, in arrival order, so the
12947
13041
  // block settles exactly when the provider moved on rather than when the next
@@ -13007,21 +13101,20 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13007
13101
  // - on a cache hit some providers report only the uncached remainder, which made the gauge
13008
13102
  // COLLAPSE mid-turn and then jump back.
13009
13103
  //
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
- }
13104
+ // PROVIDER REPORT ARRIVES -> the estimate it covers is done.
13105
+ //
13106
+ // The counter is `settled + estimate`: finished steps plus the one streaming now. When a usage
13107
+ // report lands, `settled` grows by that step's exact figure AND `estimate` still holds the
13108
+ // same step's guess, so the row showed roughly double — measured 25 estimated + 25 settled
13109
+ // reading 50 — and every later step built on the inflated total.
13110
+ //
13111
+ // Clearing `estimate` here is what makes the estimate a LIVE figure for the step in flight
13112
+ // rather than a running sum that never forgets.
13113
+ if (e.usage && Number.isFinite(e.usage.output)) {
13114
+ state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
13115
+ state.turnOutputEstimate = 0;
13116
+ state.turnOutputTokens = state.turnOutputSettled;
13117
+ }
13025
13118
  refreshCost(state, cfg);
13026
13119
  viewCur.renderSoon(); return;
13027
13120
  }
@@ -13099,6 +13192,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13099
13192
  // Track the key argument as it streams so "Using Bash (cmd)" shows
13100
13193
  // the command DURING the run rather than only once it has finished.
13101
13194
  entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
13195
+ entry._cache = null; // the argument is part of the rendered line
13102
13196
  if (entry.toolName === 'Write') {
13103
13197
  entry.streamContent = argStreamValue(entry._argStream, 'content');
13104
13198
  // Incremental line count so the preview's "… N more lines" note is
@@ -14159,6 +14253,7 @@ export async function startTUI(opts) {
14159
14253
  }
14160
14254
  let lastFrame = null;
14161
14255
  let paintScheduled = false;
14256
+ let lastPaintAt = 0; // when the last frame actually went to the terminal
14162
14257
  // Deadline for the next FULL repaint. A differential painter compares against the frame
14163
14258
  // it BELIEVES it painted, never the real screen, so any divergence between the two is
14164
14259
  // permanent: it survives every later frame because the comparison keeps saying "that row
@@ -14193,6 +14288,7 @@ export async function startTUI(opts) {
14193
14288
  state._hitboxes = frame.hitboxes || [];
14194
14289
  state._composerMeta = frame.composerMeta || [];
14195
14290
  if (out) stdout.write(out);
14291
+ lastPaintAt = now;
14196
14292
  // "Painted" becomes observable here: everything that reaches the screen goes
14197
14293
  // through this one function, so the tick advancing means bytes left this process.
14198
14294
  // See src/flush-tick.js for what reads it.
@@ -14239,11 +14335,26 @@ export async function startTUI(opts) {
14239
14335
  */
14240
14336
  function countOutputTokens(n) { return sessHost.countOutputTokens(n); }
14241
14337
 
14242
- function renderSoon() {
14243
- if (paintScheduled) return;
14244
- paintScheduled = true;
14245
- setImmediate(paintNow);
14246
- }
14338
+ /**
14339
+ * The render rate this session is capped at, from `/settings` → Render FPS.
14340
+ *
14341
+ * Read on every call rather than cached: the setting can change mid-session, and a cached rate would
14342
+ * only take effect after a restart.
14343
+ */
14344
+ function renderFps() {
14345
+ const v = Number(cfg && cfg.render_fps);
14346
+ // A terminal cannot render at 0Hz; the lowest meaningful rate is one frame a second.
14347
+ return Number.isFinite(v) ? Math.max(1, Math.round(v)) : 30;
14348
+ }
14349
+
14350
+ function renderSoon() {
14351
+ if (paintScheduled) return;
14352
+ const budget = Math.max(1, Math.round(1000 / renderFps()));
14353
+ const wait = Math.max(0, (lastPaintAt || 0) + budget - Date.now());
14354
+ paintScheduled = true;
14355
+ if (wait === 0) { setImmediate(paintNow); return; }
14356
+ setTimeout(paintNow, wait);
14357
+ }
14247
14358
  function openPicker(p) {
14248
14359
  let footer = p.footer ? { focused: false, ...p.footer } : null;
14249
14360
  if (p.footerFor) {
@@ -15353,7 +15464,7 @@ export async function startTUI(opts) {
15353
15464
  ],
15354
15465
  resolve,
15355
15466
  };
15356
- viewCur.render();
15467
+ renderSoon();
15357
15468
  }),
15358
15469
  }))
15359
15470
  .then((res) => {
@@ -16588,7 +16699,7 @@ export async function startTUI(opts) {
16588
16699
  // swallowing them bought nothing and cost the one thing a long plan needs: a way to
16589
16700
  // read past the fold. Everything else still belongs to the review.
16590
16701
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
16591
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
16702
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
16592
16703
  renderFrame();
16593
16704
  return;
16594
16705
  }
@@ -16811,7 +16922,7 @@ export async function startTUI(opts) {
16811
16922
  // answer, so "own ALL input" does not need them — and without this a long question
16812
16923
  // with an option list taller than the screen could not be read past at all.
16813
16924
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
16814
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
16925
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
16815
16926
  renderFrame();
16816
16927
  return;
16817
16928
  }
@@ -16833,7 +16944,7 @@ export async function startTUI(opts) {
16833
16944
  // its own arrow behaviour on the un-modified keys.
16834
16945
  if (t.key === 'c-up' || t.key === 'c-down') {
16835
16946
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16836
- scrollChat(state, t.key === 'c-up' ? 3 : -3);
16947
+ scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
16837
16948
  renderFrame();
16838
16949
  return;
16839
16950
  }
@@ -16842,6 +16953,21 @@ export async function startTUI(opts) {
16842
16953
  // Ctrl+L — expand / collapse the plugin log block(s). The last one is the
16843
16954
  // useful target (it is the most recent), but every pluginLog message is toggled
16844
16955
  // so a session with several reloads behaves predictably.
16956
+ if (t.key === 'alt-r') {
16957
+ // Only when nothing owns the keyboard: a picker or a dialog has its own meaning for R.
16958
+ if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16959
+ const current = (cfg && cfg.provider) || '';
16960
+ if (!current) {
16961
+ notice('No provider selected — use /model to pick one.', 'error');
16962
+ } else {
16963
+ // Fire and forget: the notice reports the outcome, and blocking the key handler on a network
16964
+ // round-trip would freeze input for as long as the endpoint takes.
16965
+ void fetchAndRegisterModels(state, cfg, current, { notice: (m, k) => notice(m, k) });
16966
+ }
16967
+ renderFrame();
16968
+ return;
16969
+ }
16970
+ }
16845
16971
  if (t.key === 'c-l') {
16846
16972
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16847
16973
  const logs = (state.chat || []).filter((m) => m && m.pluginLog);
@@ -17084,7 +17210,7 @@ export async function startTUI(opts) {
17084
17210
  renderFrame(); return;
17085
17211
  }
17086
17212
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
17087
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
17213
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
17088
17214
  renderFrame(); return;
17089
17215
  }
17090
17216
  if (t.key === 'up' || t.key === 'down') {
@@ -17565,12 +17691,13 @@ export async function startTUI(opts) {
17565
17691
  return;
17566
17692
  }
17567
17693
  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;
17694
+ // HOW HARD THE USER IS TURNING, from the interval between key events.
17695
+ //
17696
+ // One rate cannot serve both gestures: a fixed one row makes a fast spin crawl, a fixed three jumps
17697
+ // past what a careful reader wanted. The interval between events is the speed, and it is already
17698
+ // in the stream — no timer, no separate gesture detection. Slow: one row. Fast: up to eight.
17699
+ const step = wheelStep(state, "wheel");
17700
+ const d = t.key === 'wheelup' ? step : -step;
17574
17701
  if (state.panel) {
17575
17702
  const p = state.panel;
17576
17703
  p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
@@ -17934,6 +18061,26 @@ export async function startTUI(opts) {
17934
18061
  // Read live, so a /external flip applies to the very next tool call.
17935
18062
  function externalAllowed() { return sessHost.externalAllowed(); }
17936
18063
 
18064
+ /**
18065
+ * How many rows one notch or keypress should move, from how fast they are arriving.
18066
+ *
18067
+ * Shared by the wheel and by Ctrl+Up/Down so the two cannot drift: they are the same gesture with
18068
+ * different hardware. The EVENT STREAM is the speed sensor — a wheel turned slowly sends notches far
18069
+ * apart and one spun sends them in a burst, and a key held down repeats at the OS repeat rate.
18070
+ *
18071
+ * `family` keeps the two clocks apart: alternating a wheel notch with a Ctrl+Up should not be read as one
18072
+ * burst of either. It also makes the FIRST event of a family one row — `Infinity` lands in the slowest
18073
+ * step, so nothing jumps on the first touch after a pause.
18074
+ */
18075
+ function wheelStep(state, family) {
18076
+ const now = Date.now();
18077
+ const at = state._stepAt || (state._stepAt = {});
18078
+ const since = at[family] ? now - at[family] : Infinity;
18079
+ at[family] = now;
18080
+ // 140ms is about the gap between deliberate notches; under 55ms they are arriving in a burst.
18081
+ return since > 140 ? 1 : since > 90 ? 2 : since > 55 ? 4 : 8;
18082
+ }
18083
+
17937
18084
  function scrollChat(state, delta) {
17938
18085
  const cols = dims().cols, rows = dims().rows;
17939
18086
  // Only the TOTAL row count is needed to clamp the scroll position. This used to
@@ -18187,7 +18334,48 @@ export async function startTUI(opts) {
18187
18334
  // calls, which is what keeps the two front-ends behaviourally identical.
18188
18335
  // Straight through to the core. It validates and throws in the same shape as before, so
18189
18336
  // the HTTP layer (which maps a throw to 409) is unaffected.
18190
- hub.actions.submit = (text) => core.submit(text);
18337
+ // `opts.new` starts a FRESH session before the prompt runs.
18338
+ //
18339
+ // The caller does not know the new session's id in advance, so asking them to create one first
18340
+ // means two requests and a round trip to learn an id that this side invents anyway. Passing
18341
+ // `new: true` lets the daemon accept a prompt against any session URL and have it run in a new
18342
+ // session instead — the same thing `/new` does, minus the slash command.
18343
+ hub.actions.submit = async (text, opts) => {
18344
+ if (opts && opts.new) {
18345
+ // Flush the outgoing session first: its todos and transcript belong to the id being replaced,
18346
+ // and `dispatch` does exactly what `/new` does, so the two cannot drift apart.
18347
+ await core.dispatch('new', '');
18348
+ // The hub's rows and event ring belong to the session that just ended. Clearing them keeps a
18349
+ // viewer from showing the previous conversation under the new id.
18350
+ hub.rows = [];
18351
+ hub.events = [];
18352
+ }
18353
+ // `core.submit` AWAITS THE WHOLE TURN, so its result is the finished turn — no polling needed.
18354
+ const out = await core.submit(text);
18355
+ // `opts.return` asks for the transcript of what just ran:
18356
+ //
18357
+ // "turn" the rows of this turn, tool results included
18358
+ // "full" every row the session holds
18359
+ // "none" (default) whatever `core.submit` returned, unchanged
18360
+ //
18361
+ // Without this a caller had to submit, wait an unknown time, then issue a second request for
18362
+ // `/api/state` — two round trips and a guess at how long the turn would take.
18363
+ const want = opts && opts.return;
18364
+ if (!want || want === 'none') return out;
18365
+ // FORCE ONE SYNC: `syncChat` does nothing while no browser is subscribed, so `hub.rows` would be
18366
+ // empty here and the caller would get an empty transcript from a turn that plainly ran.
18367
+ try { hub.syncChat(state.chat || []); } catch { /* the reply still carries `result` */ }
18368
+ // The turn begins at the last user row; everything after it is what the prompt produced.
18369
+ const rows = Array.isArray(hub.rows) ? hub.rows : [];
18370
+ let from = 0;
18371
+ for (let k = rows.length - 1; k >= 0; k--) { if (rows[k] && rows[k].role === 'user') { from = k; break; } }
18372
+ const slice = want === 'full' ? rows : rows.slice(from);
18373
+ const detail = (opts && opts.detail) || 'full';
18374
+ const shaped = detail === 'minimal' ? slice.filter((r) => r.role !== 'tool')
18375
+ : detail === 'summary' ? slice.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r))
18376
+ : slice;
18377
+ return { result: out, rows: shaped, detail, sessionId: session && session.id };
18378
+ };
18191
18379
  hub.actions.dispatch = async (cmd, arg) => {
18192
18380
  const out = await core.dispatch(cmd, arg);
18193
18381
  // The core runs the command; the repaint is this front end's business, so it stays