@hncode/hncode 0.5.6 → 0.6.0

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.
Files changed (56) hide show
  1. package/package.json +37 -38
  2. package/src/agent.js +67 -2
  3. package/src/image-row.js +111 -0
  4. package/src/latex.js +1557 -0
  5. package/src/llm.js +19 -4
  6. package/src/mermaid/ansi.js +55 -0
  7. package/src/mermaid/canvas.js +449 -0
  8. package/src/mermaid/class-style.js +57 -0
  9. package/src/mermaid/css-colors.js +153 -0
  10. package/src/mermaid/diagrams/class.js +289 -0
  11. package/src/mermaid/diagrams/er.js +201 -0
  12. package/src/mermaid/diagrams/flowchart.js +488 -0
  13. package/src/mermaid/diagrams/gitgraph.js +205 -0
  14. package/src/mermaid/diagrams/mindmap.js +101 -0
  15. package/src/mermaid/diagrams/pie.js +104 -0
  16. package/src/mermaid/diagrams/sequence.js +282 -0
  17. package/src/mermaid/diagrams/state.js +292 -0
  18. package/src/mermaid/diagrams/timeline.js +99 -0
  19. package/src/mermaid/graph.js +138 -0
  20. package/src/mermaid/index.js +59 -0
  21. package/src/mermaid/labels.js +339 -0
  22. package/src/mermaid/layout-seq.js +225 -0
  23. package/src/mermaid/layout.js +1454 -0
  24. package/src/mermaid/registry.js +54 -0
  25. package/src/mermaid/source-box.js +102 -0
  26. package/src/mermaid/statements.js +235 -0
  27. package/src/mermaid/types.js +1 -0
  28. package/src/mermaid/width.js +29 -0
  29. package/src/screen-diff.js +512 -0
  30. package/src/screen-paint.js +128 -0
  31. package/src/screen.js +277 -0
  32. package/src/subagent-types.js +136 -147
  33. package/src/term-caps.js +10 -4
  34. package/src/tex/font.js +292 -0
  35. package/src/tex/fonts/LICENSE.txt +11 -0
  36. package/src/tex/fonts/STIX2Math.ttf +0 -0
  37. package/src/tex/index.js +293 -0
  38. package/src/tex/kitty.js +186 -0
  39. package/src/tex/layout.js +446 -0
  40. package/src/tex/parse.js +331 -0
  41. package/src/tex/raster.js +216 -0
  42. package/src/tex/render.js +167 -0
  43. package/src/tex/sixel.js +326 -0
  44. package/src/tex/svg.js +237 -0
  45. package/src/tool-result.js +2 -2
  46. package/src/tools/agent-swarm.js +3 -15
  47. package/src/tools/agent.js +454 -464
  48. package/src/tools/ask-user-question.js +195 -206
  49. package/src/tools/bash.js +39 -3
  50. package/src/tools/edit.js +10 -112
  51. package/src/tools/fetch-url.js +130 -120
  52. package/src/tools/memory.js +80 -88
  53. package/src/tools/read.js +27 -23
  54. package/src/tools/todo.js +8 -13
  55. package/src/tools/validate-args.js +78 -0
  56. package/src/tui.js +1346 -502
package/src/tui.js CHANGED
@@ -58,6 +58,13 @@ import { resolveProvider, resolveModelArg, addProvider, removeProvider, addModel
58
58
  import { pluginCommands, renderWidgets, runHooks, runPatch, runPatchSync, runKeybindsSync, pluginKeybinds, setPluginLoggerTui } from './plugin.js';
59
59
  import { OTHER_LABEL, SUPPLEMENT_LABEL } from './tools/ask-user-question.js';
60
60
  import { render as renderTodos } from './tools/todo.js';
61
+ import { renderLatex } from './latex.js';
62
+ import { measureTex, encodeTexImage, TEX_MAX_ROWS } from './tex/index.js';
63
+ import { isImageRow, imageBytes, parseImageRow, encodeImageRow as encodeImageRowHelper } from './image-row.js';
64
+ import { createScreen, pools as screenPools } from './screen.js';
65
+ import { paintRows, screenToRows } from './screen-paint.js';
66
+ import { diffScreens, placementKey, placementRows, setImageRegenerator } from './screen-diff.js';
67
+ import { render as renderMermaid, sourceBox as mermaidSourceBox, toAnsi as mermaidAnsi } from './mermaid/index.js';
61
68
  import { getTool, toolNames } from './tools/index.js';
62
69
  import { decideFromRules, describeRules, selfTestRules, describeSelfTest } from './permissions.js';
63
70
  import { isDestructiveCommand as shellIsDestructiveCommand, isReadOnlyCommand as shellIsReadOnlyCommand } from './shell-safety.js';
@@ -427,7 +434,7 @@ export const TIPS = [
427
434
  'Queued messages wait for the turn to finish',
428
435
  '/steer injects a message into the running turn',
429
436
  'Ctrl+R reloads the current session from disk',
430
- 'Alt+R re-fetches the provider\'s model list',
437
+ 'Alt+R re-fetches the provider\'s model list',
431
438
  '/help shows available commands at any time',
432
439
  '/undo rolls back the transcript AND the files',
433
440
  '/fork copies the session so you can branch',
@@ -552,8 +559,8 @@ export function reducedMotionOn(state) {
552
559
  * tool, a thinking preview) gets the same treatment from one place.
553
560
  */
554
561
  export function spinnerFrame(state, tick) {
555
- if (reducedMotionOn(state)) {
556
- const t = tick !== undefined ? tick : (state && state.spin) || 0;
562
+ if (reducedMotionOn(state)) {
563
+ const t = tick !== undefined ? tick : (state && state.spin) || 0;
557
564
  return { glyph: REDUCED_MOTION_DOT, dim: Math.floor(t / (REDUCED_MOTION_CYCLE_MS / 80)) % 2 === 1 };
558
565
  }
559
566
  const n = SPINNER.length;
@@ -647,7 +654,7 @@ export function sweepPulse(dist, half, edge = 'cosine', flat = 0) {
647
654
  * glint is a point travelling along the row, not a wash sweeping over it. It stops at one rather than
648
655
  * shrinking to zero because a two-character row still needs something to see.
649
656
  */
650
- export const LIGHT_FRACTION = 1 / 4;
657
+ export const LIGHT_FRACTION = 1 / 4;
651
658
 
652
659
  /**
653
660
  * Milliseconds per unit of `state.spin`.
@@ -679,7 +686,7 @@ export function lightWidth(n) {
679
686
  * palettes: the peak moves one letter at a time, and the row reads as a lamp travelling rather than as a
680
687
  * single pixel flashing.
681
688
  */
682
- export const TAIL_CHARS = 4;
689
+ export const TAIL_CHARS = 4;
683
690
 
684
691
 
685
692
  /**
@@ -694,7 +701,7 @@ export const TAIL_CHARS = 4;
694
701
  */
695
702
  function totalHalf(n) {
696
703
  const want = lightWidth(n) + 2 * TAIL_CHARS;
697
- const capped = Math.min(want, Math.max(2, n - 1));
704
+ const capped = Math.min(want, Math.max(2, n - 1));
698
705
  return capped / 2;
699
706
  }
700
707
 
@@ -713,44 +720,44 @@ function coreHalf(n) {
713
720
  *
714
721
  * At 30fps a frame is 33ms, which is what makes the crossing in `CROSS_MS` six frames long.
715
722
  */
716
- export const ANIM_FPS = 30;
723
+ export const ANIM_FPS = 30;
717
724
  export const FRAME_MS = 1000 / ANIM_FPS;
718
725
 
719
726
  /**
720
727
  * How long one step of the light lasts, in milliseconds.
721
728
  *
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.
729
+ * FIXED at 80ms — fast enough that a sweep reads as motion, slow enough that the eye is not chasing it.
730
+ *
731
+ * It followed the output rate for a while. That was tried and dropped: a model writing slowly got a
732
+ * sweep so slow it looked stuck, a fast one got a blur, and the same row changed pace between turns for
733
+ * no reason the reader could see. One rate is calmer and predictable, which is what an ambient
734
+ * indicator needs to be.
735
+ */
736
+ export const STEP_MS = 65;
737
+
738
+ /**
739
+ * The step for a TOOL name, slower than the Working row's.
740
+ *
741
+ * A tool name is short — `Read` is four characters — so at the Working row's rate the light crosses it in
742
+ * 560ms and the row is dark for the rest of the cycle. On screen that reads as a single flash, not as
743
+ * movement. 150ms per character gives a four-character name a 1.4s round: slow enough to follow, still
744
+ * obviously running.
745
+ */
746
+ const TOOL_STEP_MS = 150;
747
+
748
+ /**
749
+ * How long the light stays on one character, from how fast tokens are arriving.
750
+ *
751
+ * `tps` is output tokens per second for the step in flight. A fast stream should look fast — the row
752
+ * keeping a leisurely 50ms step while tokens pour in reads as the animation lagging behind the text it
753
+ * is supposed to accompany.
754
+ *
755
+ * The clamp is the important half. Under about 10 tok/s the model is thinking more than writing and the
756
+ * sweep slows down so it does not look frantic over a trickle; above about 80 tok/s it is a wall of text
757
+ * and the light moves quickly but never strobes. 28ms is just under a 33ms render frame, so a step is
758
+ * always painted at least once; 200ms is slow enough to read as deliberate without looking stuck.
728
759
  */
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
-
760
+
754
761
 
755
762
 
756
763
 
@@ -765,22 +772,22 @@ const TOOL_STEP_MS = 150;
765
772
  * ended; `lightMargin` takes the larger of the two.
766
773
  */
767
774
  const ENTRY_EXIT_CHARS = 10;
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
- }
775
+ /**
776
+ * The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger —
777
+ * but CAPPED so the light reaches the text in reasonable time.
778
+ *
779
+ * Ten cells either side is what makes the timing match Claude Code on a long row. On a SHORT row it is
780
+ * most of the cycle: `Edit` is four characters, so the travel is 24 cells and the light spends the first
781
+ * 500ms still outside the text, showing nothing. A row that looks frozen for half a second and then
782
+ * blinks once reads as broken, which is exactly how the Using row was reported.
783
+ *
784
+ * The cap is `n + 4` per side at most, so a short row spends a few steps entering rather than ten, and a
785
+ * long row is unchanged.
786
+ */
787
+ function lightMargin(n) {
788
+ const half = totalHalf(Math.max(1, n));
789
+ return Math.max(1, Math.min(ENTRY_EXIT_CHARS, Math.max(half, Math.ceil(Math.max(1, n) / 4))));
790
+ }
784
791
 
785
792
  /**
786
793
  * The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
@@ -788,9 +795,9 @@ function lightMargin(n) {
788
795
  * `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only `count * stepMs`
789
796
  * of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
790
797
  */
791
- export function crossMs(count, stepMs = STEP_MS) {
798
+ export function crossMs(count, stepMs = STEP_MS) {
792
799
  const cells = Math.max(1, count) + 2 * lightMargin(count);
793
- return cells * stepMs;
800
+ return cells * stepMs;
794
801
  }
795
802
 
796
803
  /**
@@ -803,7 +810,7 @@ export function crossMs(count, stepMs = STEP_MS) {
803
810
  * Claude Code can use zero because its entry run is ten cells: the light is already off the row for a
804
811
  * second before it comes back. Ours enters from three cells away, too short to cover the wrap.
805
812
  */
806
- export const REST_MS = 300;
813
+ export const REST_MS = 300;
807
814
 
808
815
  /**
809
816
  * The full cycle for a row: one crossing, then the rest.
@@ -811,8 +818,8 @@ export const REST_MS = 300;
811
818
  * `count` enters into it: a longer row has more characters to light, so its cycle is longer. It used to be
812
819
  * constant, because a fixed-time crossing spread itself over however many cells there happened to be.
813
820
  */
814
- export function cycleMs(count, stepMs = STEP_MS) {
815
- return crossMs(count, stepMs) + REST_MS;
821
+ export function cycleMs(count, stepMs = STEP_MS) {
822
+ return crossMs(count, stepMs) + REST_MS;
816
823
  }
817
824
 
818
825
  /**
@@ -893,7 +900,7 @@ function lightOrigin(key, anchor, now) {
893
900
  * 1.9 seconds for `Working...`. Taking milliseconds decouples the two: the caller may redraw as often
894
901
  * as it likes, and the light's speed is stated directly rather than implied by a frame count.
895
902
  */
896
- export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
903
+ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
897
904
  const n = Math.max(1, count);
898
905
  // Two widths, and the distinction is the shape of the light: `half` is the OUTER edge (core plus the
899
906
  // fade either side) and `core` is the part that stays at full brightness.
@@ -918,9 +925,9 @@ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS
918
925
  // The margin is `max(10, half)`: ten is the target, but the light must clear its own width or it would
919
926
  // still be lighting the last characters when the round ended — the failure the half-width version was
920
927
  // written to fix, and which a flat ten reintroduces on a wide band.
921
- const margin = lightMargin(n);
922
- const travel = n + 2 * margin;
923
- const steps = Math.floor((phaseMs / crossMs(n, stepMs)) * travel);
928
+ const margin = lightMargin(n);
929
+ const travel = n + 2 * margin;
930
+ const steps = Math.floor((phaseMs / crossMs(n, stepMs)) * travel);
924
931
  const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
925
932
  // `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
926
933
  // where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
@@ -948,13 +955,27 @@ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS
948
955
  * @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
949
956
  * @returns {string} ANSI, ready to splice into a row
950
957
  */
951
- function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
958
+ function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS, sweepWidth) {
959
+ // THE SWEEP PACE IS SET BY THE ROW, NOT BY THE WORD.
960
+ //
961
+ // A tool name is four characters (`Read`) in a row of eighty. Sweeping the four made the light cross in a
962
+ // few frames — each character changed colour inside one frame, which reads as a SNAP, and the harder you
963
+ // look the less it looks like motion. The fix is not to sweep the empty cells instead (they are never
964
+ // painted, so the light would be invisible for most of its travel and the word would sit dark); it is to
965
+ // run the SAME sweep geometry over a wider notional row and place the glyphs inside it, which slows the
966
+ // light down without moving where it appears.
967
+ //
968
+ // `lightAt` measures its geometry in cells, so the pace comes from `count`. Passing the row's width makes
969
+ // the crossing take proportionally longer; the glyphs stay at their own indices, and the light still visits
970
+ // every one of them, just over more frames.
952
971
  const s = String(text == null ? '' : text);
953
972
  const n = s.length;
954
973
  if (!n) return '';
974
+ // Glyphs keep their own positions; only the timing is stretched. `sweepWidth` is the row's usable width.
975
+ const pace = sweepWidth > n ? Math.round(sweepWidth) : n;
955
976
  const out = [];
956
977
  for (let i = 0; i < n; i++) {
957
- const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
978
+ const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, pace, edge, stepMs)));
958
979
  const r = Math.round(base[0] + (glint[0] - base[0]) * t);
959
980
  const g = Math.round(base[1] + (glint[1] - base[1]) * t);
960
981
  const b = Math.round(base[2] + (glint[2] - base[2]) * t);
@@ -1563,58 +1584,58 @@ export function lineDiff(oldStr, newStr, startLine = 1) {
1563
1584
 
1564
1585
  // Word-wrap a string that may contain ANSI colour codes, measuring VISIBLE
1565
1586
  // width only. Escape sequences are carried over to the line they belong to.
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
- }
1587
+ function wrapAnsiWords(str, width) {
1588
+ width = Math.max(1, width | 0);
1589
+ const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
1590
+ const out = [];
1591
+ let line = '';
1592
+ let lineW = 0;
1593
+ // THE COLOUR ACTIVE AT THE BREAK, carried onto the next row.
1594
+ //
1595
+ // A colour is opened once and closed at the end of the string, so a line wrapped mid-colour leaves the
1596
+ // continuation starting with plain text — the terminal paints it in its DEFAULT foreground, which on a
1597
+ // dark theme is brighter than the grey the rest of the block uses. That is what a wrapped, expanded
1598
+ // command looked like: the first row grey and every row below it lighter.
1599
+ //
1600
+ // Tracked from the escapes seen so far and re-emitted at the start of each new row, then cleared. `0m`
1601
+ // resets it, so a line that closes its colour before the break does not reopen one.
1602
+ let active = '';
1603
+ const scan = (text) => {
1604
+ for (const m of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
1605
+ active = m[1] === '0' || m[1] === '' ? '' : `\x1b[${m[1]}m`;
1606
+ }
1607
+ };
1608
+ const push = () => { if (line !== '') out.push(line); line = active; lineW = 0; };
1609
+ for (const tok of tokens) {
1610
+ if (tok === '') continue;
1611
+ const tw = visualCol(tok);
1612
+ if (/^\s+$/.test(tok)) {
1613
+ if (lineW > 0) { line += tok; lineW += tw; }
1614
+ continue;
1615
+ }
1616
+ if (lineW > 0 && lineW + tw > width) push();
1617
+ scan(tok);
1618
+ if (tw > width && visualCol(tok) > width) {
1619
+ // Hard-split an over-long token by visible columns.
1620
+ let chunk = '';
1621
+ let cw = 0;
1622
+ for (const ch of tok) {
1623
+ const chw = visualCol(ch);
1624
+ if (cw + chw > width) { out.push(chunk); chunk = active; cw = 0; }
1625
+ chunk += ch; cw += chw;
1626
+ }
1627
+ if (chunk) { line = chunk; lineW = cw; }
1628
+ continue;
1629
+ }
1630
+ line += tok;
1631
+ lineW += tw;
1632
+ }
1633
+ if (line !== '') out.push(line);
1634
+ if (out.length === 0) out.push('');
1635
+ // Rows that were seeded with a carried colour but ended up holding nothing are dropped: they would
1636
+ // render as an invisible escape-only line.
1637
+ return out.map((s) => s.replace(/^\x1b\[[0-9;]*m$/, '')).filter((s) => s !== '' || out.length === 1);
1638
+ }
1618
1639
 
1619
1640
  // Word-aware wrap that also returns each row's starting offset in the source
1620
1641
  // string, so the caret can be mapped to a (row, col) inside the composer.
@@ -2125,7 +2146,7 @@ function keyArgument(name, args, workspace, expanded = false) {
2125
2146
  }
2126
2147
  return '';
2127
2148
  }
2128
- export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false) {
2149
+ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false, width = 0) {
2129
2150
  const name = msg.toolName || '';
2130
2151
  const arg = keyArgument(name, msg.toolArgs, workspace, expanded);
2131
2152
  const verb = msg.pending ? 'Using' : 'Used';
@@ -2169,21 +2190,25 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2169
2190
  // `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time to
2170
2191
  // zero so the light never moved at all.
2171
2192
  const anchorSpin = pulseStart ?? spin;
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}`;
2193
+ // THE KEY IS THE ROW'S OWN IDENTITY, and nothing else.
2194
+ //
2195
+ // It used to carry `anchorSpin` as well, on the reasoning that the same message re-animated on a new
2196
+ // tick should get a new origin. The opposite happened: `anchorSpin` is `pulseStart ?? spin`, and when
2197
+ // the caller passes no `pulseStart` it IS the tick counter — it changes every 80ms, so every frame
2198
+ // built a new key, `lightOrigin` created a fresh origin for it, and `elapsedMs` stayed at 0. The light
2199
+ // sat still, except for the one frame after the row content changed and the message object was
2200
+ // replaced.
2201
+ //
2202
+ // A row animates from the moment it appears until it stops being pending, and its id is stable across
2203
+ // that whole life: one origin, and elapsed grows from there.
2204
+ const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}` : `tool:${name}`;
2184
2205
  const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
2185
- // ONE shimmer for both rows — see `shimmerText`.
2186
- const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
2206
+ // The sweep's geometry is measured over the ROW, so its cycle has to be measured over the row too.
2207
+ // `crossMs(name.length)` describes a crossing of four cells; the phase would wrap long before a row-wide
2208
+ // light had moved a fifth of the way, and the name would sit at one brightness forever.
2209
+ const spentW = visualCol('● ') + visualCol(verb) + 1;
2210
+ const sweepW = width > 0 ? Math.max(name.length, width - spentW) : name.length;
2211
+ const phaseMs = elapsedMs % cycleMs(sweepW, TOOL_STEP_MS);
2187
2212
 
2188
2213
  // The same restraint as the Working row's glint: the light is the base colour with a small lift in
2189
2214
  // the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
@@ -2191,12 +2216,12 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2191
2216
  const BASE = [0, 215, 255]; // cyan: the name's resting colour
2192
2217
  const GLINT = [110, 240, 255]; // the same hue, a shade brighter
2193
2218
 
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
- }
2219
+ // The same setting the Working row honours: a pending tool's name is decoration too.
2220
+ if (reducedMotion) {
2221
+ nameDisplay = col(name, C.cyan);
2222
+ } else {
2223
+ nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS, sweepW);
2224
+ }
2200
2225
  } else {
2201
2226
  // Non-pending: show in plain cyan
2202
2227
  nameDisplay = col(name, C.cyan);
@@ -2457,11 +2482,13 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2457
2482
  // Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
2458
2483
  // The body already carries its own ANSI colours, so mark it pre-colored.
2459
2484
  if (msg.role === 'tool') {
2460
- const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion);
2485
+ // The bullet's width is known before the body is built, and the body needs it: a pending tool's name is
2486
+ // swept across the row it has, not across its own four characters.
2487
+ const bodyW = Math.max(1, width - visualCol('● '));
2488
+ const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion, bodyW);
2461
2489
  // Red bullet for a failed call (a Bash non-zero exit / [error: …]).
2462
2490
  const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
2463
2491
  const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
2464
- const bodyW = Math.max(1, width - visualCol('● '));
2465
2492
  // EXPANDED: break the body at the newlines the command actually has, so a multi-line script renders
2466
2493
  // as the lines it was written as. `wrapAnsiWords` alone flattens a newline into a space, turning
2467
2494
  // `set -e\nnpm ci\nnpm test` into one long reflowed line — and the reason to expand is to read the
@@ -3001,12 +3028,16 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
3001
3028
  const { headRows, tailRows, headCut } = renderMdIncremental(msg, String(msg.text || ''), bodyW, fg, codeFg);
3002
3029
  const c = msg._mdCache;
3003
3030
  let headWrapped = c && c.headWrapped;
3004
- if (!headWrapped || c.headWrappedCut !== headCut) {
3031
+ if (!headWrapped || c.headWrappedCut !== headCut || c.headWrappedVersion !== MD_RENDER_VERSION) {
3005
3032
  // ONLY the first row of the whole message carries the `❯` marker; every later
3006
3033
  // row uses the continuation pad. Using `prefix` for every head row put a `❯`
3007
3034
  // on each line of the message.
3008
3035
  headWrapped = headRows.map((ml, i) => ({ text: ml, ind: i === 0 ? prefix : contPad, raw: true }));
3009
- if (c) { c.headWrapped = headWrapped; c.headWrappedCut = headCut; }
3036
+ if (c) {
3037
+ c.headWrapped = headWrapped;
3038
+ c.headWrappedCut = headCut;
3039
+ c.headWrappedVersion = MD_RENDER_VERSION;
3040
+ }
3010
3041
  }
3011
3042
  for (const w of headWrapped) lines.push(w);
3012
3043
  const tailBase = lines.length;
@@ -3053,22 +3084,38 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
3053
3084
  // separately. That is NOT equivalent — a trailing blank line contributes a row on
3054
3085
  // its own but acts as a separator inside the whole document — so the two renders
3055
3086
  // disagreed by a row. Blocks avoid the problem entirely.)
3087
+ /**
3088
+ * Version of the markdown renderer, part of the incremental cache key.
3089
+ *
3090
+ * That cache is validated on the INPUT — text, width, colours — which cannot see that the renderer
3091
+ * itself now produces different rows for the same input. A session that cached a block before a fence
3092
+ * type learned to draw kept showing the old output, so the change looked like it had not shipped.
3093
+ * Bump this whenever what `renderMdText` produces for the same input changes.
3094
+ */
3095
+ const MD_RENDER_VERSION = 7; // 7: an image row carries the TeX that produced it
3096
+
3056
3097
  export function renderMdIncremental(msg, text, width, fg, codeFg) {
3057
3098
  const src = text.replace(/\r\n/g, '\n');
3058
3099
  let c = msg._mdCache;
3059
3100
  // Layout identity: same width/colours and the same text. Comparing the text (not
3060
3101
  // just its length) is required — a same-length replacement must re-render.
3061
- if (c && c.width === width && c.fg === fg && c.codeFg === codeFg && c.text === src) {
3102
+ if (c && c.v === MD_RENDER_VERSION && c.width === width && c.fg === fg && c.codeFg === codeFg && c.text === src) {
3062
3103
  return { headRows: c.headRows, tailRows: c.tailRows, headCut: c.headCut };
3063
3104
  }
3064
- if (!c || c.width !== width || c.fg !== fg || c.codeFg !== codeFg) {
3065
- c = msg._mdCache = { width, fg, codeFg };
3105
+ if (!c || c.v !== MD_RENDER_VERSION || c.width !== width || c.fg !== fg || c.codeFg !== codeFg) {
3106
+ c = msg._mdCache = { v: MD_RENDER_VERSION, width, fg, codeFg };
3066
3107
  }
3067
3108
 
3068
3109
  const parts = splitBlocks(src);
3069
3110
  // A block is final once a LATER block exists; the last one is still growing.
3070
3111
  const headCount = Math.max(0, parts.length - 1);
3071
- const canReuse = Array.isArray(c.blocks)
3112
+ // THE CACHED BLOCKS BELONG TO A VERSION OF THE RENDERER. Without that check a request for the same text,
3113
+ // width and colours reuses rows produced by an OLDER renderer: the fast path above rejects them on `c.v`,
3114
+ // but it leaves `c` untouched (its width and colours still match), and this reuse test then finds the block
3115
+ // texts identical and hands back the old rows. That is why an image row kept arriving without the TeX it
3116
+ // was made from after the field was added.
3117
+ const canReuse = c.v === MD_RENDER_VERSION
3118
+ && Array.isArray(c.blocks)
3072
3119
  && Array.isArray(c.blockTexts)
3073
3120
  && c.blockTexts.length >= headCount
3074
3121
  && parts.length >= headCount
@@ -3081,6 +3128,7 @@ export function renderMdIncremental(msg, text, width, fg, codeFg) {
3081
3128
  const headCut = parts.length > 1 ? parts.slice(0, headCount).join('\n').length : 0;
3082
3129
 
3083
3130
  msg._mdCache = {
3131
+ v: MD_RENDER_VERSION,
3084
3132
  text: src, width, fg, codeFg,
3085
3133
  blocks, blockTexts: parts.slice(0, headCount),
3086
3134
  headRows, tailRows, headCut,
@@ -3126,16 +3174,112 @@ export function renderMdText(text, width, fg, codeFg) {
3126
3174
  while (i < para.length) {
3127
3175
  let line = para[i];
3128
3176
  const t = line.replace(/\s+$/, '');
3177
+ // DISPLAY MATH: `$$ ... $$` and `\[ ... \]`.
3178
+ //
3179
+ // A fence is what a model writes when it wants a code block, but a formula usually arrives as `$$`.
3180
+ // It is laid out here as the formula it describes — stacked fractions, limits above a sum — and its
3181
+ // source is what remains when the subset cannot express it, exactly as a math fence behaves.
3182
+ // The opener has to start the line and the closer has to end one, so `cost is $$x$$ total` stays prose
3183
+ // and is handled as inline math by the line renderer below.
3184
+ const mathOpen = /^\s*(\$\$|\\\[)/.exec(t);
3185
+ if (mathOpen) {
3186
+ const open = mathOpen[1];
3187
+ const close = open === '$$' ? '$$' : '\\]';
3188
+ const after = t.slice(mathOpen.index + mathOpen[0].length);
3189
+ let body = null;
3190
+ let used = 0;
3191
+ let closed = true;
3192
+ // One-line form: `$$x = 1$$` with nothing but whitespace after the closer.
3193
+ const oneLine = after.indexOf(close);
3194
+ if (oneLine >= 0 && after.slice(oneLine + close.length).trim() === '') {
3195
+ body = after.slice(0, oneLine);
3196
+ used = 1;
3197
+ } else if (after.trim() === '') {
3198
+ // Multi-line form: everything up to the line that ends with the closer.
3199
+ const collected = [];
3200
+ let k = i + 1;
3201
+ let found = false;
3202
+ while (k < para.length) {
3203
+ const cl = para[k].replace(/\s+$/, '');
3204
+ if (cl.trimEnd().endsWith(close)) {
3205
+ collected.push(cl.slice(0, cl.lastIndexOf(close)));
3206
+ k++;
3207
+ found = true;
3208
+ break;
3209
+ }
3210
+ collected.push(para[k]);
3211
+ k++;
3212
+ }
3213
+ body = collected.join('\n');
3214
+ used = k - i;
3215
+ // Still streaming: no closer yet, so the body is prose plus half a formula.
3216
+ closed = found;
3217
+ }
3218
+ if (body !== null && closed) {
3219
+ i += used;
3220
+ // Same three fallbacks as a math fence: bitmap, Unicode layout, then the source.
3221
+ const rows = mathBlockRows(body, width, true);
3222
+ if (rows) {
3223
+ out.push(...rows);
3224
+ } else {
3225
+ // Unsupported: show the TeX so the reader still gets the formula's meaning.
3226
+ for (const l of body.split('\n')) out.push(col(' ' + l.slice(0, Math.max(1, width - 2)), C.white));
3227
+ }
3228
+ continue;
3229
+ }
3230
+ }
3129
3231
  // Fenced code block. Rendered with a rule ABOVE (labelled with the language)
3130
3232
  // and a matching full-width rule BELOW, so the block is visibly delimited.
3131
- const fm = /^\s*(```+|~~~+)\s*(\S*)/.exec(t);
3233
+ const fm = /^\s*(`{3,}|~{3,})\s*(\S*)/.exec(t);
3132
3234
  if (fm) {
3133
3235
  const lang = fm[2] || '';
3134
3236
  i++;
3135
3237
  const buf = [];
3136
- while (i < para.length && !/^\s*(```+|~~~+)/.test(para[i])) { buf.push(para[i]); i++; }
3137
- i++; // closing fence
3238
+ const fenceChar = fm[1][0];
3239
+ const fenceLen = fm[1].length;
3240
+ const isCloser = (s) => {
3241
+ const m = /^\s*(`{3,}|~{3,})\s*$/.exec(s);
3242
+ return !!m && m[1][0] === fenceChar && m[1].length >= fenceLen;
3243
+ };
3244
+ while (i < para.length && !isCloser(para[i])) { buf.push(para[i]); i++; }
3245
+ // Whether the closing fence was actually reached: an open fence means the text is still streaming.
3246
+ const fenceClosed = i < para.length;
3247
+ i++; // closing fence (no-op when the fence is still open)
3138
3248
  const rule = (s) => col(s, C.gray);
3249
+ // MATH FENCES ARE TYPESET. A `latex`/`tex`/`math` fence is TeX, not code: it is drawn as the formula
3250
+ // it describes, and when the terminal speaks an image protocol it is drawn as a TYPESET BITMAP rather
3251
+ // than as flattened Unicode — the same pipeline dsh-tui uses, with STIX Two Math.
3252
+ //
3253
+ // Three fallbacks, in order, and each one keeps the formula: the bitmap; the Unicode layout when the
3254
+ // picture cannot be placed (no protocol, no cell size, too wide, too tall); the SOURCE when the subset
3255
+ // cannot express the TeX at all.
3256
+ if (/^(latex|tex|math)$/i.test(lang)) {
3257
+ const tex = buf.join('\n').trim();
3258
+ const rendered = mathBlockRows(tex, width, fenceClosed);
3259
+ if (rendered) {
3260
+ out.push(...rendered);
3261
+ continue;
3262
+ }
3263
+ // Falls through to the plain code block: the source is all we have.
3264
+ }
3265
+ if (/^mermaid$/i.test(lang)) {
3266
+ const src = buf.join('\n');
3267
+ let art = null;
3268
+ try { art = renderMermaid(src); } catch { art = null; }
3269
+ // Wider than the panel: the frame is the honest answer, since truncating art
3270
+ // would cut a box in half rather than say the diagram does not fit.
3271
+ if (art && art.width <= width) {
3272
+ // `styled` keeps the role of every span, so the frame stays dim and the
3273
+ // connectors cyan instead of the whole diagram arriving as plain white text.
3274
+ const coloured = mermaidAnsi(art);
3275
+ for (const l of coloured) out.push(l);
3276
+ continue;
3277
+ }
3278
+ try {
3279
+ for (const l of mermaidSourceBox(src, width).plain) out.push(col(l, C.white));
3280
+ continue;
3281
+ } catch { /* fall through to the plain code block */ }
3282
+ }
3139
3283
  // Opening rule: `─ <lang> ` then `─` padding out to the FULL width.
3140
3284
  // (The old version subtracted 4 and was 2 columns short of the panel.)
3141
3285
  if (lang) {
@@ -3147,13 +3291,12 @@ export function renderMdText(text, width, fg, codeFg) {
3147
3291
  // Fenced code block CONTENT is plain body text — render it in the
3148
3292
  // default terminal colour (white), not the theme's cyan. The cyan is
3149
3293
  // reserved for UI chrome and the language label; a large code block in
3150
- // cyan reads as "everything is the theme colour" (matches the "assistant
3151
- // text should be white" requirement).
3294
+ // cyan reads as "everything is the theme colour".
3152
3295
  for (const cl of buf) out.push(col(cl.slice(0, width), C.white));
3153
3296
  // Closing rule, matching the opening one.
3154
3297
  out.push(rule('─'.repeat(Math.max(1, width))));
3155
3298
  continue;
3156
- }
3299
+ }
3157
3300
  // Table: a header line followed by a separator line
3158
3301
  if (t.includes('|') && i + 1 < para.length && isTableSep(para[i + 1])) {
3159
3302
  const header = splitCells(t);
@@ -3270,8 +3413,8 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
3270
3413
  return out;
3271
3414
  }
3272
3415
  }
3273
- // Inline styles: **bold**, *italic*, `code`, [text](url)
3274
- if (/[*`\[]/.test(t)) {
3416
+ // Inline styles: **bold**, *italic*, `code`, [text](url), $math$
3417
+ if (/[*`\[$]|\\\(/.test(t)) {
3275
3418
  for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg, fg), fg));
3276
3419
  return out;
3277
3420
  }
@@ -3280,6 +3423,151 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
3280
3423
  return out;
3281
3424
  }
3282
3425
 
3426
+ /**
3427
+ * Whether the terminal can receive a formula as a picture.
3428
+ *
3429
+ * Both halves are required: a protocol the terminal reported (Sixel today) and a cell size it reported
3430
+ * (XTWINOPS 16). Without the cell size the picture would be measured in guessed cells, which is what put a
3431
+ * formula on the wrong rows before — so a missing cell size falls back to text rather than guess.
3432
+ */
3433
+ function mathImageCapable() {
3434
+ const caps = terminalCaps();
3435
+ if (!caps) return null;
3436
+ // KITTY FIRST. A terminal that speaks both (Kitty can be built with Sixel support) handles Kitty far
3437
+ // better: the pixels are uploaded once and every later frame is a placement, while a Sixel has to be
3438
+ // retransmitted and moves the cursor when it is drawn.
3439
+ const protocol = caps.kittyGraphics
3440
+ ? 'kitty'
3441
+ : (Array.isArray(caps.extensions) && caps.extensions.includes('sixel') ? 'sixel' : null);
3442
+ if (!protocol) return null;
3443
+ const cell = caps.cell && caps.cell.w > 0 && caps.cell.h > 0 ? caps.cell : null;
3444
+ return cell ? { protocol, cell } : null;
3445
+ }
3446
+
3447
+ /** Ink colour for a typeset formula: the body text colour, so a picture sits in the text rather than beside it. */
3448
+ const MATH_INK = { r: 220, g: 220, b: 220 };
3449
+
3450
+ /**
3451
+ * The terminal's cell height in pixels, for cropping a picture that straddles the top of the window.
3452
+ *
3453
+ * A crop has to convert CELL ROWS to six-pixel BANDS; without the cell height the two units get confused and
3454
+ * the image is drawn from the wrong point in its own payload. 20 is the conventional monospace cell height and
3455
+ * matches the fallback `src/tex/` uses when the terminal never answers XTWINOPS 16.
3456
+ *
3457
+ * @returns {number}
3458
+ */
3459
+ function mathCellHeight() {
3460
+ const caps = terminalCaps();
3461
+ const cell = caps && caps.cell;
3462
+ return cell && cell.h > 0 ? cell.h : 20;
3463
+ }
3464
+
3465
+ /**
3466
+ * Rows for one display formula: a typeset bitmap when the terminal can show one, the Unicode layout when it
3467
+ * cannot, and nothing when the TeX is outside the subset (the caller then shows the source).
3468
+ *
3469
+ * @param {string} tex TeX without delimiters.
3470
+ * @param {number} width Columns available.
3471
+ * @param {boolean} closed The closing delimiter arrived. A formula still streaming keeps to the text form: a
3472
+ * picture of half a formula is worse than the TeX of half a formula.
3473
+ * @returns {string[]|null}
3474
+ */
3475
+ function mathBlockRows(tex, width, closed) {
3476
+ const src = stripMathDelimiters(tex);
3477
+ if (!src) return null;
3478
+ // A picture sits where the text form does: one column of indent, and no wider than the panel.
3479
+ const room = Math.max(8, width - 2);
3480
+ if (closed) {
3481
+ const placed = placeTex(src, room);
3482
+ if (placed) return placed;
3483
+ }
3484
+ const rendered = renderLatex(src, { display: true });
3485
+ if (rendered === undefined) return null;
3486
+ return rendered
3487
+ .split('\n')
3488
+ .map((l) => col(l.slice(0, width), C.white));
3489
+ }
3490
+
3491
+ /**
3492
+ * Remove the delimiters a formula may still be wrapped in.
3493
+ *
3494
+ * A `$$…$$` block and a ```latex fence both reach the typesetter, and the fence's content may carry the
3495
+ * delimiters too — a model writes the fence around `$$…$$` often enough that it is the common case, not the
3496
+ * odd one. Typesetting the delimiters along with the formula draws them as glyphs inside the picture, which
3497
+ * is what a literal `$$` in the middle of an integral looks like.
3498
+ *
3499
+ * Only a delimiter that spans the WHOLE body is removed: a `$$` in the middle is TeX the author meant, and
3500
+ * stripping it would change the formula. The loop runs because a doubled wrapper (`\n$$\n…\n$$\n` inside a
3501
+ * fence) leaves one pair behind after the first pass.
3502
+ *
3503
+ * @param {string} tex
3504
+ * @returns {string}
3505
+ */
3506
+ function stripMathDelimiters(tex) {
3507
+ let s = String(tex || '').trim();
3508
+ for (;;) {
3509
+ const before = s;
3510
+ if (s.startsWith('$$') && s.endsWith('$$') && s.length > 4) {
3511
+ s = s.slice(2, -2).trim();
3512
+ } else if (s.startsWith('\\[') && s.endsWith('\\]') && s.length > 4) {
3513
+ s = s.slice(2, -2).trim();
3514
+ } else if (s.startsWith('\\(') && s.endsWith('\\)') && s.length > 4) {
3515
+ s = s.slice(2, -2).trim();
3516
+ }
3517
+ if (s === before) return s;
3518
+ }
3519
+ }
3520
+
3521
+
3522
+ /**
3523
+ * Typeset a formula into a frame row, or null when it cannot be placed as a picture here.
3524
+ *
3525
+ * The picture is one row of TEXT plus a payload: the row is the sentinel `image-row.js` understands, the
3526
+ * rows below it are blank, and the frame writer turns them into a cursor move and a Sixel sequence. Every
3527
+ * reason to decline is a reason the caller already has a text fallback for, so this never throws.
3528
+ *
3529
+ * @param {string} tex
3530
+ * @param {number} room Columns the picture may occupy.
3531
+ */
3532
+ function placeTex(tex, room) {
3533
+ const capable = mathImageCapable();
3534
+ if (!capable) return null;
3535
+ try {
3536
+ // Measured first, because laying out is what rejects TeX outside the subset: a raster would be paid for
3537
+ // and then thrown away.
3538
+ const measured = measureTex(tex, { cell: capable.cell });
3539
+ if (!measured || measured.columns > room || measured.rows > TEX_MAX_ROWS) return null;
3540
+ const image = encodeTexImage(tex, { protocol: capable.protocol, cell: capable.cell, colour: MATH_INK });
3541
+ if (!image) return null;
3542
+ // THE SOURCE TRAVELS WITH THE ROW. A picture that straddles the top of the window has to be redrawn from
3543
+ // its first VISIBLE row, and only the TeX can produce that raster: cropping the encoded payload can only
3544
+ // land on a six-pixel band boundary, which leaves up to a quarter of a cell of the formula above the
3545
+ // window and makes it slide as the view scrolls. `paintCellFrame` reads this back and re-renders with
3546
+ // `skipTop` when a picture needs cropping.
3547
+ const rows = [encodeImageRowHelper(image.sequence, image.rows, image.columns, tex)];
3548
+ for (let r = 1; r < image.rows; r++) rows.push('');
3549
+ return rows;
3550
+ } catch {
3551
+ // A bitmap that cannot be drawn is a reason to show text, never a reason to lose the frame.
3552
+ return null;
3553
+ }
3554
+ }
3555
+
3556
+ /**
3557
+ * Whether a `$...$` body is a formula rather than prose.
3558
+ *
3559
+ * Prices, shell variables and identifiers all use `$`: `costs $5 and $10`, `$HOME`, `${VAR}`. A body that
3560
+ * starts or ends with a space, is a bare number or an all-caps word is prose, and so is one with no TeX
3561
+ * command and no operator in it — `vitamin $C$` stays text while `$x^2$` becomes a formula.
3562
+ */
3563
+ function looksLikeInlineMath(body) {
3564
+ if (/^\s/.test(body) || /\s$/.test(body)) return false;
3565
+ if (body.startsWith('{') || body.includes('`')) return false;
3566
+ if (/^\d+([.,]\d+)?$/.test(body)) return false;
3567
+ if (/^[A-Z][A-Z0-9_]*$/.test(body)) return false;
3568
+ return /\\[A-Za-z]+|[=+\-*/<>^_]/.test(body) || (/^[A-Za-z]$/.test(body));
3569
+ }
3570
+
3283
3571
  // Apply inline markdown to a single line: `code`, **bold**, *italic*, ~~strike~~,
3284
3572
  // [text](url). Order matters so inner tokens are handled safely.
3285
3573
  // `base` is the surrounding foreground colour; every inline style re-asserts it
@@ -3291,6 +3579,17 @@ function inlineMarkdown(s, codeFg, base) {
3291
3579
  let r = s;
3292
3580
  // Inline code first so it is not mangled by * [] ~.
3293
3581
  r = r.replace(/`([^`]+)`/g, (_, c) => col(c, codeFg) + fb);
3582
+ // INLINE MATH. After the code spans, so a formula inside backticks stays code, and before emphasis, so
3583
+ // `*` and `_` inside TeX cannot turn into italics. `\(...\)` is the other inline form models use.
3584
+ r = r.replace(/\$([^$\n]+?)\$|\\\(([^\n]+?)\\\)/g, (m, dollar, paren) => {
3585
+ const tex = dollar !== undefined ? dollar : paren;
3586
+ // A price or a shell variable is not math: `$5 and $10`, `$HOME`, `${VAR}`.
3587
+ if (dollar !== undefined && !looksLikeInlineMath(tex)) return m;
3588
+ const out = renderLatex(tex, { display: false });
3589
+ // Unsupported or multi-line: the source stands, because inline math sits inside wrapped prose.
3590
+ if (out === undefined || out.includes('\n')) return m;
3591
+ return out;
3592
+ });
3294
3593
  // Bold
3295
3594
  r = r.replace(/\*\*([^*]+)\*\*/g, (_, c) => C.bold + c + C.reset + fb);
3296
3595
  // Strikethrough
@@ -3656,12 +3955,12 @@ export function renderChatLines(state, w, viewport) {
3656
3955
  const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
3657
3956
  const rowWidth = border ? boxText : innerW;
3658
3957
  const cached = m._cache;
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;
3958
+ // Which rows animate? A pending tool call and a running compaction block do, and their appearance
3959
+ // depends on the current step of the light. Everything else gets a constant key, so a static row is
3960
+ // laid out once and reused across frames.
3961
+ // A pending tool advances from the wall clock at TOOL_STEP_MS, so its cache key is THAT step index.
3962
+ const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
3963
+ const spinKeyNow = animating ? Math.floor(Date.now() / TOOL_STEP_MS) : 0;
3665
3964
  const valid = cached
3666
3965
  && cached.rowW === rowWidth && cached.expanded === expanded
3667
3966
  && cached.textRef === m.text && cached.streamRef === m.streamContent
@@ -3715,8 +4014,11 @@ export function renderChatLines(state, w, viewport) {
3715
4014
  // happen (replacing a word). Those are caught by the `_cache` nulling at the
3716
4015
  // edit sites, which is what the shared-store path below relies on too.
3717
4016
  const countLen = typeof m.text === 'string' ? m.text.length : 0;
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) {
4017
+ // Only a NON-ANIMATING message can be answered from its row count. See the note below.
4018
+ // A message that may hold a bitmap cannot be answered from a row count: the count says how many rows it
4019
+ // occupies, not what is drawn in them.
4020
+ const hasFence = /```+\s*(latex|tex|math)\b/i.test(typeof m.text === 'string' ? m.text : '');
4021
+ if (!hasFence && !valid && !cached && !animating && m._count && m._count.key === countKey && m._count.textLen === countLen) {
3720
4022
  msgLen[mi] = m._count.len + rules;
3721
4023
  cursor += msgLen[mi];
3722
4024
  prevBordered = border;
@@ -3741,12 +4043,13 @@ export function renderChatLines(state, w, viewport) {
3741
4043
  && shared.liveRef === m.liveOutput && shared.diffRef === m.diff
3742
4044
  && shared.countsRef === m._diffCounts
3743
4045
  && shared.keyArgs === (m.toolArgs ? fpArgs(m.toolArgs) : '0')
4046
+ && shared.v === MD_RENDER_VERSION
3744
4047
  && shared.pluginExpanded === (m.pluginLog ? !!m.expanded : null)) {
3745
4048
  m._cache = shared;
3746
4049
  msgLen[mi] = shared.rows.length + rules;
3747
4050
  } else {
3748
4051
  const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3749
- state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
4052
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
3750
4053
  reducedMotionOn(state));
3751
4054
  const wrapped = rows.map(rowToLine);
3752
4055
  const isPending2 = m.pending ? 1 : 0;
@@ -3762,6 +4065,7 @@ export function renderChatLines(state, w, viewport) {
3762
4065
  pluginExpanded: m.pluginLog ? !!m.expanded : null,
3763
4066
  raw: !!state.rawMode,
3764
4067
  rows: wrapped, spinRows: null,
4068
+ v: MD_RENDER_VERSION, // invalidates rows built by an older renderer
3765
4069
  };
3766
4070
  for (let i = 0; i < wrapped.length; i++)
3767
4071
  if (wrapped[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
@@ -3887,27 +4191,30 @@ export function renderChatLines(state, w, viewport) {
3887
4191
  && rowStart[mi - 1] + msgLen[mi - 1] === cursor;
3888
4192
  }
3889
4193
  const len = msgLen[mi];
3890
- const visible = visibleTo < 0 || (cursor + len > visibleFrom && cursor < visibleTo);
4194
+ // A message that may contain a bitmap is always built, visible or not: a row COUNT cannot represent a
4195
+ // picture, and a stale payload is worse than no payload.
4196
+ const mayHoldImage = /```+\s*(latex|tex|math)\b/i.test(typeof msg.text === 'string' ? msg.text : '');
4197
+ const visible = mayHoldImage
4198
+ || visibleTo < 0
4199
+ || (cursor + len > visibleFrom && cursor < visibleTo);
3891
4200
  if (visible) {
3892
4201
  // Pass 1 may have skipped building the rows for a message far from the
3893
4202
  // viewport (it only needed the row COUNT, so it used `_count`). Now that the
3894
4203
  // message IS visible, build them here — otherwise an evicted message rendered
3895
4204
  // as blank rows, which is what made scrolled-back history look like it had
3896
4205
  // "not loaded". The row width must match what pass 1 counted with.
3897
- let ccc = msg._cache;
3898
- if (!ccc || !ccc.rows || ccc.rows.length !== len - (bordered ? (shared ? 1 : 2) : 0)) {
3899
- const bw = bordered ? boxText : innerW;
3900
- const src = messageLines(msg, bw, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3901
- state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
3902
- const built = src.map(rowToLine);
3903
- ccc = { rows: built, spinRows: null };
3904
- for (let i = 0; i < built.length; i++) {
3905
- if (built[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
3906
- if (!ccc.spinRows) ccc.spinRows = new Set();
3907
- ccc.spinRows.add(i);
3908
- }
4206
+ // Rows are re-derived every frame. Everything expensive inside `messageLines` is cached under a
4207
+ // COMPLETE key (the markdown block cache) or in the shared row LRU; what used to be cached HERE was the
4208
+ // final string, validated on a row count that cannot see a clipped payload.
4209
+ const bw = bordered ? boxText : innerW;
4210
+ const src = messageLines(msg, bw, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
4211
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
4212
+ const ccc = { rows: src.map(rowToLine), spinRows: null };
4213
+ for (let i = 0; i < ccc.rows.length; i++) {
4214
+ if (ccc.rows[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
4215
+ if (!ccc.spinRows) ccc.spinRows = new Set();
4216
+ ccc.spinRows.add(i);
3909
4217
  }
3910
- msg._cache = ccc;
3911
4218
  }
3912
4219
  const spinRows = ccc.spinRows;
3913
4220
  const liveAt = (r, i) => (spinRows && spinRows.has(i) ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
@@ -3916,10 +4223,20 @@ export function renderChatLines(state, w, viewport) {
3916
4223
  const pad = ' '.repeat(boxPad);
3917
4224
  const wall = col('│', boxColor);
3918
4225
  for (let i = 0; i < ccc.rows.length; i++) {
3919
- out.push(PAD + wall + pad + fitAnsi(liveAt(ccc.rows[i], i), boxText) + pad + wall);
4226
+ const rowText = liveAt(ccc.rows[i], i);
4227
+ if (isImageRow(rowText)) {
4228
+ // Emitted as-is: the frame writer turns it into a cursor move plus the bitmap.
4229
+ out.push(rowText);
4230
+ continue;
4231
+ }
4232
+ out.push(PAD + wall + pad + fitAnsi(rowText, boxText) + pad + wall);
3920
4233
  }
3921
4234
  } else {
3922
- for (let i = 0; i < ccc.rows.length; i++) out.push(PAD + liveAt(ccc.rows[i], i));
4235
+ for (let i = 0; i < ccc.rows.length; i++) {
4236
+ const rowText = liveAt(ccc.rows[i], i);
4237
+ // An image row must arrive at the cursor with nothing in front of its introducer.
4238
+ out.push(isImageRow(rowText) ? rowText : PAD + rowText);
4239
+ }
3923
4240
  }
3924
4241
  if (bordered) out.push(rule(false, boxColor));
3925
4242
  } else {
@@ -3969,10 +4286,10 @@ export function metricsSignature(state, n, w, expanded, innerW) {
3969
4286
  mix(m.pluginLog && m.expanded ? 1 : 0);
3970
4287
  mix(m._cache ? 1 : 0);
3971
4288
  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));
4289
+ // A pending row repaints as the light moves, with none of the fields above changing. Without this
4290
+ // the signature matched every frame and the transcript was never re-walked, so the animation froze
4291
+ // until the message itself was touched.
4292
+ if (m.pending) mix(Math.floor(Date.now() / STEP_MS));
3976
4293
  }
3977
4294
  return h;
3978
4295
  }
@@ -4405,58 +4722,118 @@ export function questionBoxClampedHeight(state, insideW, h, chromeH) {
4405
4722
  // the thumb's travel is monotonic, uses every row, and lands exactly on both
4406
4723
  // ends; `thumbLenF` is kept unrounded purely so the grab offset inside the thumb
4407
4724
  // is not quantised to a whole row when the thumb is 10+ rows tall.
4725
+ /**
4726
+ * Sub-cell resolution of the scrollbar, in eighths of a row.
4727
+ *
4728
+ * The block glyphs `▁▂▃▄▅▆▇█` ARE a vertical eighths scale, so the bar can sit at any of eight heights
4729
+ * inside a cell. Using only two (`▀`/`▄`) gave the thumb a handful of reachable positions on a long
4730
+ * transcript — measured, 35 positions for 482 rows of scrolled content, so it jumped ~14 rows at a time
4731
+ * and the motion read as a series of hops.
4732
+ */
4733
+ const SUB_CELLS = 8;
4734
+
4735
+ /**
4736
+ * The scrollbar thumb is this many cells tall, always.
4737
+ *
4738
+ * A constant height, because the bar reports POSITION rather than density. Proportional sizing is
4739
+ * tempting and wrong here: it collapsed to a fraction of a cell on a long transcript, then grew a cell
4740
+ * at a time as content arrived, so the control changed size while being used.
4741
+ */
4742
+ const THUMB_CELLS = 2;
4743
+
4408
4744
  export function scrollbarGeometry({ total, bodyH, scroll }) {
4409
4745
  const maxScroll = Math.max(0, total - bodyH);
4410
4746
  const pos = Math.min(maxScroll, Math.max(0, scroll || 0));
4411
- // OpenTUI works on a 2x virtual grid so the thumb has HALF-ROW resolution: each
4412
- // real row is two virtual units, and a thumb boundary landing inside a cell is
4413
- // drawn as `▀` (top half) or `▄` (bottom half). That is what makes a short
4414
- // scrollbar look smooth instead of jumping a whole row at a time. See OpenTUI
4415
- // Slider.ts: getVirtualThumbSize / getVirtualThumbStart / renderVertical.
4416
- const virtualTrack = bodyH * 2;
4417
- // Thumb size = viewport / (content + viewport) of the track, min one virtual unit.
4418
- const contentSize = maxScroll + bodyH;
4419
- const virtualThumbSize = bodyH >= total
4420
- ? virtualTrack
4421
- : Math.max(1, Math.min(
4422
- Math.floor(virtualTrack * (bodyH / Math.max(1, contentSize))),
4423
- virtualTrack,
4424
- ));
4425
- // 0 = bottom (pinned), maxScroll = top. Row 0 is the TOP of the body, so the
4426
- // virtual start counts from the top (OpenTUI's value grows downward; ours
4427
- // grows upward from the newest line, hence the inversion).
4428
- const fromTop = maxScroll === 0 ? 0 : (maxScroll - pos) / maxScroll;
4429
- const maxVirtualStart = Math.max(0, virtualTrack - virtualThumbSize);
4430
- const virtualThumbStart = Math.round(fromTop * maxVirtualStart);
4431
-
4432
- // Row-range + fractional forms, kept for the drag maths and the hit-test, which
4433
- // reason in whole rows. Derived from the virtual values so paint and drag agree.
4434
- const thumbLenF = Math.max(1, virtualThumbSize / 2);
4435
- const thumbStart = Math.floor(virtualThumbStart / 2);
4436
- const thumb = Math.max(1, Math.ceil((virtualThumbStart + virtualThumbSize) / 2) - thumbStart);
4437
- const maxThumbStartF = Math.max(0, bodyH - thumbLenF);
4747
+
4748
+ if (total <= bodyH || bodyH <= 0) {
4749
+ // Everything fits: no rail, and nothing is drawn for a control that would have nothing to say.
4750
+ return {
4751
+ thumbStart: 0,
4752
+ thumbLen: bodyH,
4753
+ thumbLenF: bodyH,
4754
+ maxThumbStartF: 0,
4755
+ virtualThumbStart: 0,
4756
+ virtualThumbSize: bodyH * SUB_CELLS,
4757
+ virtualTrack: bodyH * SUB_CELLS,
4758
+ bodyH,
4759
+ total,
4760
+ maxScroll,
4761
+ };
4762
+ }
4763
+
4764
+ // PROPORTIONAL HEIGHT, WHOLE ROWS, at least one — Codewhale's rule:
4765
+ // thumb = ceil(rail * visible / totalRows), clamped to [1, rail]
4766
+ // travel = rail - thumb
4767
+ // top = floor(travel * offset / maxOffset)
4768
+ //
4769
+ // It works in whole rows and so does this. Sub-cell positions were tried at length here: every scheme
4770
+ // that put an edge inside a cell had to state the fraction with `▀` (four eighths) or leave it blank,
4771
+ // so the same thumb drew between two and five eighths tall depending on where it sat, and the bar
4772
+ // visibly changed length as it scrolled. One row of thumb is the smallest honest unit.
4773
+ const visible = Math.max(1, Math.min(total, bodyH));
4774
+ const thumbLen = Math.max(1, Math.min(bodyH, Math.ceil((bodyH * visible) / total)));
4775
+ const travel = Math.max(0, bodyH - thumbLen);
4776
+ // `scroll` counts UP from the tail, the rail runs top-down, so the offset is inverted here.
4777
+ const thumbStart = maxScroll === 0 ? 0 : Math.min(travel, travel - Math.floor((travel * pos) / maxScroll));
4438
4778
 
4439
4779
  return {
4440
- thumbStart, thumbLen: thumb, thumbLenF, maxThumbStartF,
4441
- virtualThumbStart, virtualThumbSize, virtualTrack,
4442
- bodyH, total, maxScroll,
4780
+ thumbStart,
4781
+ thumbLen,
4782
+ thumbLenF: thumbLen,
4783
+ maxThumbStartF: travel,
4784
+ virtualThumbStart: thumbStart * SUB_CELLS,
4785
+ virtualThumbSize: thumbLen * SUB_CELLS,
4786
+ virtualTrack: bodyH * SUB_CELLS,
4787
+ bodyH,
4788
+ total,
4789
+ maxScroll,
4443
4790
  };
4444
4791
  }
4445
4792
 
4446
- // The glyph for one body row, exactly OpenTUI's per-cell rule (Slider.ts
4447
- // renderVertical): full coverage -> `█`; a half-covered cell -> `▀` when the thumb
4448
- // starts in the upper half of that cell, `▄` when it starts in the lower half;
4449
- // no coverage -> ` ` (the track shows through as its background colour).
4793
+ /**
4794
+ * The glyph for one body row, from the thumb's coverage of that row.
4795
+ *
4796
+ * `▁▂▃▄▅▆▇█` are eighths of a cell filled from the BOTTOM, which is the natural scale for a vertical bar
4797
+ * whose trailing edge is what the eye follows. A full row is `█`; a row the thumb ends inside shows the
4798
+ * fraction covered; a row the thumb starts inside shows `▀` when at least half of it is covered, and is
4799
+ * left blank otherwise so the bar never claims more than the thumb holds.
4800
+ *
4801
+ * That asymmetry is deliberate: the leading edge is only half-cell accurate, because no glyph fills an
4802
+ * arbitrary fraction from the TOP. The trailing edge carries the fine motion — eight steps per row
4803
+ * instead of two, so a short thumb travels visibly instead of hopping a whole row at a time.
4804
+ */
4805
+ /** The vertical eighth-blocks, tallest last. */
4806
+ /**
4807
+ * One row of the scrollbar.
4808
+ *
4809
+ * The thumb is drawn from its BOTTOM edge upward: `▁▂▃▄▅▆▇█` fill a cell from the floor, so a row that
4810
+ * holds the thumb's bottom edge can state exactly how much of it is covered. That keeps the drawn height
4811
+ * equal to the thumb's real height to within one subunit.
4812
+ *
4813
+ * A row that holds only the thumb's TOP edge is the hard case: nothing fills from the ceiling by a
4814
+ * fraction, so the choice is `▀` (four subunits) or nothing. It is used only when the coverage really is
4815
+ * at least four — using it for less is what made the bar draw seven subunits for a five-subunit thumb,
4816
+ * i.e. visibly grow and shrink as it moved.
4817
+ */
4818
+ /**
4819
+ * One row of the scrollbar, assembled so the drawn height matches the thumb.
4820
+ *
4821
+ * A cell can hold only one character, and the available pieces are:
4822
+ * `▁▂▃▄▅▆▇█` — filled from the FLOOR by 1..8 eighths
4823
+ * `▀` — the TOP half (four eighths)
4824
+ *
4825
+ * So the two edges are handled by whichever piece fits them: a cell holding the thumb's BOTTOM edge
4826
+ * reaches the floor and the eighths say exactly how much; a cell holding its TOP edge gets `▀` when at
4827
+ * least half of it is covered. Composing the two across adjacent rows keeps the total within one eighth
4828
+ * of the thumb's real height — measured, the painted height varies by at most 1 subunit instead of the
4829
+ * 3 it did when a whole cell was guessed at once.
4830
+ */
4450
4831
  export function scrollbarGlyph(g, row) {
4451
- if (!g) return ' ';
4452
- const cellStart = row * 2;
4453
- const cellEnd = cellStart + 2;
4454
- const start = Math.max(g.virtualThumbStart, cellStart);
4455
- const end = Math.min(g.virtualThumbStart + g.virtualThumbSize, cellEnd);
4456
- const coverage = end - start;
4457
- if (coverage >= 2) return '█';
4458
- if (coverage > 0) return (start - cellStart) === 0 ? '▀' : '▄';
4459
- return ' ';
4832
+ if (!g || row < 0 || row >= g.bodyH) return ' ';
4833
+ // Codewhale draws the thumb as a solid block and the rest of the rail as a vertical line, so the
4834
+ // control reads as a track with a handle on it rather than as a stick floating on nothing.
4835
+ if (g.total <= g.bodyH) return ' '; // nothing to scroll: no rail
4836
+ return row >= g.thumbStart && row < g.thumbStart + g.thumbLen ? '█' : '│';
4460
4837
  }
4461
4838
 
4462
4839
  // Map a position on the track to a transcript scroll offset. `rowF` is the mouse
@@ -5215,6 +5592,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5215
5592
  const bodyH = Math.max(1, h - bottomH);
5216
5593
 
5217
5594
  const lines = [];
5595
+ const body = []; // the whole transcript; `lines` receives the window onto it
5218
5596
  let dialogCaret = null;
5219
5597
  // The AskUserQuestion box's free-text caret, recorded as an absolute frame row
5220
5598
  // (the box sits mid-frame, not at the top), corrected with topPad/topTrim below.
@@ -5598,13 +5976,24 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5598
5976
  if (state.selection && state.selection.anchor && state.selection.head) {
5599
5977
  applySelection(chat, state.selection);
5600
5978
  }
5601
- for (let i = 0; i < bodyH; i++) {
5602
- const idx = start + i;
5979
+ // EVERY TRANSCRIPT ROW, not just the visible ones: the frame describes the whole content, so a change
5980
+ // in height above the window is visible to the diff. `start` is the window offset, used by the painter.
5981
+ for (let i = 0; i < Math.max(total, bodyH); i++) {
5982
+ const idx = i;
5983
+ // Where this row lands on screen. The window starts at `start` (`-padding` while the transcript is
5984
+ // shorter than the body), so a row outside [0, bodyH) is composed but never painted.
5985
+ const screenRow = idx - Math.max(0, start);
5603
5986
  let row = '';
5604
5987
  if (idx >= 0 && idx < total) {
5605
5988
  row = chat[idx] || '';
5606
5989
  }
5607
- if (sb) {
5990
+ // THE BAR IS ADDRESSED BY SCREEN ROW, not by transcript row.
5991
+ //
5992
+ // It used to pass the transcript index here while the loop still walked the whole transcript, so the
5993
+ // rail was drawn from the geometry of the FIRST `bodyH` rows of the conversation and every row actually
5994
+ // on screen fell outside that range — the bar came out blank as soon as the transcript was scrolled off
5995
+ // the top, i.e. in the normal case, and only appeared when the window happened to start at row 0.
5996
+ if (sb && screenRow >= 0 && screenRow < bodyH) {
5608
5997
  // The scrollbar cell is drawn EXACTLY the way OpenTUI's SliderRenderable draws
5609
5998
  // it (packages/core/src/renderables/Slider.ts, `renderVertical`):
5610
5999
  //
@@ -5622,8 +6011,8 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5622
6011
  // (`ESC[2m`), which fades the FOREGROUND only, so an open picker left the
5623
6012
  // thumb's background half at full brightness while the rest of the screen
5624
6013
  // receded.
5625
- const glyphChar = scrollbarGlyph(sb, i);
5626
- const onThumb = glyphChar !== ' ';
6014
+ const glyphChar = scrollbarGlyph(sb, screenRow);
6015
+ const onThumb = glyphChar === '█'; // the rail is `│`, the handle is `█`
5627
6016
  let fg;
5628
6017
  if (onThumb) {
5629
6018
  if (state.sbDrag) fg = C.scrollThumbActiveFg;
@@ -5633,9 +6022,13 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5633
6022
  fg = C.scrollTrackFg;
5634
6023
  }
5635
6024
  const bg = C.scrollTrackBg;
5636
- row = fitAnsi(row, w - 1) + fg + bg + glyphChar + C.reset;
6025
+ // A bitmap row is a terminal payload: clipping it to make room for the scrollbar truncates the
6026
+ // sequence. The picture is placed by the frame writer, so it needs the row verbatim.
6027
+ row = isImageRow(row) ? row : fitAnsi(row, w - 1) + fg + bg + glyphChar + C.reset;
5637
6028
  }
5638
- lines.push(row);
6029
+ body.push(row);
6030
+ // Only the window reaches the frame; the rest lives in `body` for the diff to compare.
6031
+ if (screenRow >= 0 && screenRow < bodyH) lines.push(row);
5639
6032
  }
5640
6033
  state._sb = sb;
5641
6034
  state._bodyTop = start;
@@ -5647,6 +6040,18 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5647
6040
  // mapping is recorded here rather than assumed — hitboxes already do the same
5648
6041
  // `+ topPad - topTrim` correction (see the end of this function) and the mouse
5649
6042
  // mapping did not, which is what made a drag copy the wrong lines.
6043
+
6044
+ // Pad the front when the transcript is shorter than the window: the blank rows belong ABOVE the first
6045
+ // message, so the content sits directly over the composer. Without this the composer followed the last
6046
+ // message and the padding pass pushed the whole body to the top of the screen.
6047
+ // A SHORT TRANSCRIPT SITS AT THE BOTTOM, ABOVE THE COMPOSER.
6048
+ //
6049
+ // When the content is shorter than the window, `start` is negative and the loop emits only the rows
6050
+ // that exist — so `lines` held five rows where the window has forty, the composer was appended
6051
+ // directly after them, and the padding pass then pushed the whole body to the TOP of the screen.
6052
+ // The blank rows belong at the FRONT: above the first message.
6053
+ const missing = bodyH - lines.length;
6054
+ if (missing > 0) for (let pad = 0; pad < missing; pad++) lines.unshift('');
5650
6055
  state._bodyScreenTop = lines.length - bodyH;
5651
6056
  // How many blank padding rows precede the first body row on screen. When
5652
6057
  // the transcript is shorter than the body, `start` is negative and those
@@ -6192,7 +6597,11 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6192
6597
  // started its sweep from an origin minutes in the past — a phase far past the cycle, so the light
6193
6598
  // snapped to a position mid-row. Keying on the turn's own start stamp makes every animation unique.
6194
6599
  const turnKey = state.turnStart || 0;
6195
- const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workMsg.length);
6600
+ // The cycle is measured over the ROW, matching the geometry `shimmerText` is given below. A cycle sized
6601
+ // to the phrase would wrap while a row-wide light had barely moved, and the phrase would hold one
6602
+ // brightness for the whole turn.
6603
+ const workSweep = Math.max(workMsg.length, cols - 2);
6604
+ const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workSweep);
6196
6605
 
6197
6606
  // THE GLINT IS A STEP AWAY FROM THE BASE, not a jump to another colour.
6198
6607
  //
@@ -6208,7 +6617,13 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6208
6617
  const chars = workMsg;
6209
6618
  const lightEdge = state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge) || 'cosine';
6210
6619
  // ONE shimmer for both rows — see `shimmerText`.
6211
- const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge);
6620
+ // THE LIGHT CROSSES THE ROW, NOT THE PHRASE.
6621
+ //
6622
+ // `Working...` is ten characters in a row that has eighty columns. Sweeping only the ten made the light
6623
+ // move one glyph per frame — a snap rather than a fade. The sweep now covers the room the row actually
6624
+ // has, so the same phrase animates at a speed the eye reads as motion. Only the phrase's own cells are
6625
+ // painted; a coloured space would smear the row's padding.
6626
+ const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge, STEP_MS, Math.max(chars.length, cols - 2));
6212
6627
  // A QUIET TURN FADES TOWARD THE WARNING COLOUR.
6213
6628
  //
6214
6629
  // `state.lastTokenAt` is stamped whenever output arrives; `state.hasActiveTools` is set while a tool
@@ -6602,7 +7017,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6602
7017
  // this point on, column N of a row IS screen column N. The final `fitAnsi` pass then
6603
7018
  // has nothing left to do for width (an `w`-wide row is returned unchanged).
6604
7019
 
6605
- for (let i = 0; i < lines.length; i++) lines[i] = fitAnsi(lines[i] == null ? '' : lines[i], w);
7020
+ // AN IMAGE ROW IS LEFT ALONE.
7021
+ //
7022
+ // `fitAnsi` pads and truncates to the panel width, and a Sixel payload is thousands of characters: this
7023
+ // pass cut every bitmap down to its header plus a few bytes, which is what the terminal then failed to
7024
+ // parse. The row stays exactly as the markdown renderer produced it.
7025
+ for (let i = 0; i < lines.length; i++) {
7026
+ const l = lines[i] == null ? '' : lines[i];
7027
+ lines[i] = isImageRow(l) ? l : fitAnsi(l, w);
7028
+ }
6606
7029
  let topPad = 0;
6607
7030
  if (lines.length < h) { topPad = h - lines.length; }
6608
7031
  while (lines.length < h) lines.unshift(' '.repeat(w));
@@ -6747,7 +7170,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6747
7170
  }
6748
7171
  }
6749
7172
 
6750
- let padded = lines.map((l) => fitAnsi(l, w));
7173
+ let padded = lines.map((l) => (isImageRow(l) ? l : fitAnsi(l, w)));
6751
7174
  // Mixin seam: beforeRender - plugins may rewrite the painted lines. Runs before the
6752
7175
  // ANSI string is built so the change actually reaches the screen.
6753
7176
  try { const out = runPatchSync('beforeRender', { lines: padded, state, cols: w, rows: h }, (c) => c); if (out && Array.isArray(out.lines)) padded = out.lines; } catch (e) {}
@@ -6763,7 +7186,66 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6763
7186
 
6764
7187
  const ansi = `\x1b[H` + padded.join('\n')
6765
7188
  + `\x1b[${cursor.row + 1};${cursor.col + 1}H` + cursorShape;
6766
- let _frame = { ansi, lines: padded, cursor, cursorVisible, width: w, height: h,
7189
+ // Lift every image payload out of the text rows. What remains in `padded` is blank, which is what the
7190
+ // rows under a bitmap should be: the terminal draws the picture there, and text must not follow it.
7191
+ // How many body rows sit above the window. The painter compares content and addresses the screen by
7192
+ // window row, so it needs both numbers; `state._bodyTop` is the body index of the window's first row.
7193
+ const viewportTop = Math.max(0, (state && state._bodyTop) || 0);
7194
+ // Lift the pictures out of BODY rows: that is the grid the painter compares. `padded` is the window cut
7195
+ // from the body afterwards, so its own copies of those rows have to be blanked as well.
7196
+ const images = [];
7197
+ // Where body row `_bodyTop` sits on the final screen. A body row only equals its screen row when the
7198
+ // window starts at the top of the transcript, so the offset is carried here rather than assumed: a picture
7199
+ // placed at its body index lands outside the frame in any session longer than the window, which is what
7200
+ // made a formula look like it had never been drawn.
7201
+ const bodyOrigin = state && state._bodyScreenTop != null
7202
+ ? { screenTop: state._bodyScreenTop, bodyTop: state._bodyTop != null ? state._bodyTop : 0 }
7203
+ : null;
7204
+ for (let i = 0; i < body.length; i++) {
7205
+ if (!isImageRow(body[i])) continue;
7206
+ const parsed = parseImageRow(body[i]);
7207
+ if (!parsed) continue;
7208
+ if (bodyOrigin) {
7209
+ const screenRow = bodyOrigin.screenTop + (i - bodyOrigin.bodyTop);
7210
+ // A PICTURE MUST STAY INSIDE THE TRANSCRIPT PANEL.
7211
+ //
7212
+ // `h` is the whole screen, but the transcript only occupies `screenTop .. screenTop + bodyH`; below it
7213
+ // are the composer and the status line. A picture whose last row ran past that boundary was drawn over
7214
+ // the composer — a formula sliding down into the input box as the view scrolled, which is what "it goes
7215
+ // to the composer before it leaves" was. The rows are clipped to the panel here, and only a picture that
7216
+ // still has a visible row inside it is kept.
7217
+ const panelTop = Math.max(0, bodyOrigin.screenTop);
7218
+ const panelBottom = Math.min(h, bodyOrigin.screenTop + bodyH);
7219
+ const firstRow = Math.max(screenRow, panelTop);
7220
+ const lastRow = Math.min(screenRow + parsed.rows, panelBottom);
7221
+ if (lastRow > firstRow) {
7222
+ // The column is part of the placement: every transcript row opens with a one-column pad. A picture
7223
+ // clipped at the top keeps its `row` negative so the painter knows how many pixel rows to cut.
7224
+ images.push({
7225
+ row: screenRow,
7226
+ rows: parsed.rows,
7227
+ cols: parsed.cols,
7228
+ seq: parsed.sequence,
7229
+ col: 1,
7230
+ // What produced the payload, so the painter can regenerate it from a later pixel row when the
7231
+ // picture straddles the top of the window. See `placeImage`.
7232
+ source: parsed.source,
7233
+ // The rows this picture may occupy, so the painter can clip the bottom as well as the top.
7234
+ clipTop: panelTop,
7235
+ clipBottom: panelBottom,
7236
+ });
7237
+ }
7238
+ }
7239
+ body[i] = '';
7240
+ for (let r = 1; r < parsed.rows && i + r < body.length; r++) body[i + r] = '';
7241
+ }
7242
+
7243
+ // The same rows, in the window array.
7244
+ for (let i = 0; i < padded.length; i++) {
7245
+ if (isImageRow(padded[i])) padded[i] = '';
7246
+ }
7247
+
7248
+ let _frame = { ansi, lines: padded, body, images, viewportTop, cursor, cursorVisible, width: w, height: h,
6767
7249
  cursorRow: cursor.row, cursorCol: cursor.col, cursorShape, hitboxes, composerMeta: composer.meta };
6768
7250
  // Mixin seam: afterRender - plugins may modify the final frame (overlay, banner).
6769
7251
  try { const out = runPatchSync('afterRender', { frame: _frame, state }, (c) => c); if (out && out.frame) _frame = out.frame; } catch (e) {}
@@ -6797,7 +7279,176 @@ const CR = '\r';
6797
7279
  // rewrites 30 rows lands as one update instead of 30 visible steps.
6798
7280
  const SYNC_BEGIN = '\x1b[?2026h';
6799
7281
  const SYNC_END = '\x1b[?2026l';
6800
- export function diffFrame(prev, next) {
7282
+
7283
+ // A FORMULA CROPPED AT A PIXEL ROW IS REGENERATED FROM ITS TEX.
7284
+ //
7285
+ // The painter crops a picture that straddles the top of the window, and it must crop on a PIXEL row, not on a
7286
+ // six-pixel band boundary: a band crop always leaves 0..5 pixels of the picture above the window, so the
7287
+ // formula shifts by up to a quarter of a cell as the view scrolls and snaps back when the leftover is zero.
7288
+ // Only the source can produce a raster cut at an arbitrary row, and only this module knows how to typeset —
7289
+ // so the function is installed here rather than imported there.
7290
+ setImageRegenerator((source, skipTop, maxColumns, maxHeight) => {
7291
+ const capable = mathImageCapable();
7292
+ if (!capable) return null;
7293
+ const image = encodeTexImage(source, {
7294
+ protocol: capable.protocol,
7295
+ cell: capable.cell,
7296
+ colour: MATH_INK,
7297
+ skipTop,
7298
+ maxWidth: maxColumns,
7299
+ maxHeight,
7300
+ });
7301
+ return image ? image.sequence : null;
7302
+ });
7303
+
7304
+ /**
7305
+ * Turn a frame into the bytes that update the screen.
7306
+ *
7307
+ * `resendImages` is false for a PERIODIC full repaint. A bitmap only has to be re-sent when the terminal
7308
+ * has lost it — the first paint, or after a resize. Windows Terminal moves the screen every time one is
7309
+ * drawn, so re-sending on the once-a-second repaint pushed the whole interface down once a second; that
7310
+ * is what left the composer repeated down the screen.
7311
+ */
7312
+ // ---- the cell-grid painter ----------------------------------------------
7313
+
7314
+ // The frame composer still produces rows of styled strings, because every surface here was written against
7315
+ // that shape. This layer paints those rows into a cell grid and paints the grid differentially, which is
7316
+ // what makes two things possible that rows cannot express: a change that rewrites only the affected
7317
+ // columns (18 bytes instead of a whole row), and a bitmap treated as a RECTANGLE with no text in it.
7318
+ //
7319
+ // The grid is kept across frames. Its pools are session-lived on purpose: an interned style id stays valid
7320
+ // as long as the process, so a comparison is an integer compare.
7321
+ const screenState = screenPools();
7322
+ let prevScreen = null;
7323
+
7324
+ /**
7325
+ * Paint one frame through the cell grid.
7326
+ *
7327
+ * @param {object} frame Output of `composeFrame`.
7328
+ * @param {boolean} resendImages Whether bitmaps must be re-sent this paint.
7329
+ * @returns {string} The bytes to write.
7330
+ */
7331
+ export function paintCellFrame(frame, resendImages) {
7332
+ const rows = frame.lines || [];
7333
+ const bodyRows = frame.body || rows;
7334
+ const width = frame.width || 80;
7335
+ const height = Math.max(1, frame.height || rows.length);
7336
+ const images = frame.images || [];
7337
+
7338
+ // THE `shownImages` RECORD IS NEVER CLEARED HERE.
7339
+ //
7340
+ // It says which rectangles the terminal currently has pictures in, and it is the only thing an ERASE can be
7341
+ // computed from: clearing it means the old pixels have no owner and can never be removed. What
7342
+ // `resendImages` controls is whether the pictures are DRAWN again — and since every picture is now drawn on
7343
+ // every frame, the flag only has to keep the reconciliation from treating a stale screen as authoritative.
7344
+
7345
+ // WHICH PICTURES THIS FRAME CAN ACTUALLY HAVE ON SCREEN, decided BEFORE any cell is touched.
7346
+ //
7347
+ // A picture that will not be drawn must leave its cells to the TEXT. Clearing them for a picture that
7348
+ // never arrives is what produced a blank rectangle over the transcript, the scrollbar and the composer:
7349
+ // the row was blanked, no pixels were painted, and the text that belonged there had already been thrown
7350
+ // away. A refused picture is therefore NOT a hole — it is the Unicode layout, which `composeFrame` has
7351
+ // already put in those cells.
7352
+ //
7353
+ // `resendImages` no longer filters anything: EVERY picture in the window is queued on every frame. What the
7354
+ // flag still does is survive to `diffScreens` as the reason the caller expects a full draw.
7355
+ const drawable = [];
7356
+ let budget = IMAGE_FRAME_BUDGET;
7357
+ for (const img of images.slice(0, IMAGE_FRAME_MAX)) {
7358
+ // Visible inside BOTH the screen and the transcript panel: a picture clipped away at the bottom no
7359
+ // longer reaches into the composer.
7360
+ const span = placementRows(img, height);
7361
+ if (span.to <= span.from) continue;
7362
+ if (img.seq.length > budget) continue; // over this frame's payload budget
7363
+ budget -= img.seq.length;
7364
+ drawable.push(img);
7365
+ }
7366
+
7367
+ const screen = createScreen(width, height, screenState.charPool, screenState.stylePool);
7368
+ paintRows(screen, rows, screenState.stylePool);
7369
+ // Blank ONLY the cells a picture will cover, and only its own columns: the last column of these rows is
7370
+ // the scrollbar, and the rest of the row still belongs to the interface.
7371
+ for (const img of drawable) {
7372
+ const span = placementRows(img, height);
7373
+ const from = Math.min(width, Math.max(0, img.col || 0) + 1);
7374
+ const to = Math.min(width, from + Math.max(0, img.cols) - 1);
7375
+ const w = to - from;
7376
+ const h = span.to - span.from;
7377
+ if (w > 0 && h > 0) screen.clear(from, span.from, w, h, 0);
7378
+ }
7379
+
7380
+ // A PICTURE IS RECONCILED, NOT DRAWN HERE.
7381
+ //
7382
+ // `diffScreens` owns the decision (see its header; dsh-tui's src/ink/sixel-graphics.ts reaches the same
7383
+ // shape): erase a placement that changed or MOVED, draw the one replacing it in the SAME write, send
7384
+ // nothing for a placement whose raster and position both still match.
7385
+ const cursorRow = (frame.cursorRow || 0);
7386
+ const cursor = `\x1b[${Math.max(1, cursorRow + 1)};${(frame.cursorCol || 0) + 1}H` + (frame.cursorShape || '');
7387
+ const out = diffScreens(prevScreen, screen, drawable.map((img) => ({ ...img, col: img.col || 0 })), {
7388
+ cursor,
7389
+ shown: shownImages,
7390
+ budget: IMAGE_FRAME_BUDGET,
7391
+ maxImages: IMAGE_FRAME_MAX,
7392
+ cellHeight: mathCellHeight(),
7393
+ before: syncOutputUsable() ? SYNC_BEGIN : '',
7394
+ after: syncOutputUsable() ? SYNC_END : '',
7395
+ });
7396
+
7397
+
7398
+
7399
+ // The rows the pictures own THIS frame, so the NEXT frame knows which rows to repaint as text once a
7400
+ // picture moves away. Taken from `drawable` — this frame's placements — and not from `shownImages`, which
7401
+ // is replaced inside the reconciliation and by this point already describes the frame that just ran. Reading
7402
+ // the record there made `imageRows` lag one frame behind: the rows a picture occupied were never registered
7403
+ // as pictures' rows, so when it scrolled away the text was diffed against a previous frame that also had no
7404
+ // text there — the row was left alone and the old glyphs stayed under the new ones.
7405
+ const owned = [];
7406
+ for (const img of drawable) {
7407
+ const span = placementRows(img, height);
7408
+ for (let y = span.from; y < span.to; y++) owned.push(y);
7409
+ }
7410
+ screen.imageRows = owned;
7411
+ screen.bodyRows = bodyRows.length;
7412
+ prevScreen = screen;
7413
+ return out;
7414
+ }
7415
+
7416
+ /**
7417
+ * What the terminal is currently believed to be showing: one entry per placement, keyed by raster AND
7418
+ * position.
7419
+ *
7420
+ * THE POSITION IS PART OF THE IDENTITY, which is the correction that matters here. Keying on the raster
7421
+ * alone treated a formula that had scrolled one row up as "already on screen", so its new row was never
7422
+ * drawn and its old row was never erased — a blank rectangle at the new position, stale pixels at the old
7423
+ * one, and the text underneath left unwritten.
7424
+ *
7425
+ * `diffScreens` reads this map and rewrites it each paint, dropping the entries the frame no longer wants,
7426
+ * so it cannot grow without bound.
7427
+ */
7428
+ const shownImages = new Map();
7429
+
7430
+ /**
7431
+ * Bytes of picture payload one frame may carry.
7432
+ *
7433
+ * The payload is the expensive half of a frame and the only part that can push the terminal's screen, so a
7434
+ * frame that would exceed this sends the first few pictures that fit and leaves the rest to their text
7435
+ * fallback. A pathological reply — fifty matrices — degrades instead of hanging the terminal.
7436
+ */
7437
+ const IMAGE_FRAME_BUDGET = 256 * 1024;
7438
+ /** At most this many pictures in one frame, for the same reason the byte budget exists. */
7439
+ const IMAGE_FRAME_MAX = 12;
7440
+
7441
+ /** Forget every picture the terminal is believed to show, so the next paint sends them all again. */
7442
+ export function resetShownImages() {
7443
+ shownImages.clear();
7444
+ }
7445
+
7446
+ /** Forget the previous grid, so the next paint is a full one. */
7447
+ export function resetCellFrame() {
7448
+ prevScreen = null;
7449
+ }
7450
+
7451
+ export function diffFrame(prev, next, resendImages = true) {
6801
7452
  let shape = '';
6802
7453
  if (!prev || prev.cursorShape !== next.cursorShape) shape = next.cursorShape;
6803
7454
  const pos = `\x1b[${next.cursorRow + 1};${next.cursorCol + 1}H`;
@@ -6812,7 +7463,23 @@ export function diffFrame(prev, next) {
6812
7463
  const sizeChanged = !prev || prev.width !== next.width || prev.height !== next.height;
6813
7464
  if (sizeChanged) {
6814
7465
  let out = before;
6815
- for (let i = 0; i < next.lines.length; i++) out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7466
+ for (let i = 0; i < next.lines.length; i++) {
7467
+ if (isImageRow(next.lines[i])) {
7468
+ const parsed = parseImageRow(next.lines[i]);
7469
+ if (!resendImages) {
7470
+ // The terminal is still showing it; only skip the rows it occupies so they are not blanked.
7471
+ if (parsed && parsed.rows > 1) i += parsed.rows - 1;
7472
+ continue;
7473
+ }
7474
+ const placed = imageBytes(next.lines[i], i, '\x1b[0m');
7475
+ if (placed) {
7476
+ out += placed.bytes;
7477
+ i += Math.max(0, placed.covered - 1);
7478
+ continue;
7479
+ }
7480
+ }
7481
+ out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7482
+ }
6816
7483
  out += after + pos + shape;
6817
7484
  return out;
6818
7485
  }
@@ -6840,8 +7507,27 @@ export function diffFrame(prev, next) {
6840
7507
  // scrolled everything up by a row and the next `ESC[2K` erased the composer's
6841
7508
  // top border — "Working… gets written on the next line and overwrites the
6842
7509
  // input box". Absolute addressing cannot scroll, so the span is now safe.
6843
- const reach = Math.min(next.lines.length, last + 1);
6844
- for (let i = first; i < reach; i++) out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7510
+ // A bitmap covers rows the text diff cannot see: extend the rewritten span to include them, or the
7511
+ // rows beneath an image keep the previous frame's content and the screen accumulates fragments.
7512
+ let reach = Math.min(next.lines.length, last + 1);
7513
+ for (let i = 0; i < next.lines.length; i++) {
7514
+ if (!isImageRow(next.lines[i])) continue;
7515
+ const p = parseImageRow(next.lines[i]);
7516
+ if (p) reach = Math.max(reach, Math.min(next.lines.length, i + p.rows));
7517
+ }
7518
+
7519
+ for (let i = first; i < reach; i++) {
7520
+ if (isImageRow(next.lines[i])) {
7521
+ const placed = imageBytes(next.lines[i], i, '\x1b[0m');
7522
+ if (placed) {
7523
+ out += placed.bytes;
7524
+ i += Math.max(0, placed.covered - 1);
7525
+ continue;
7526
+ }
7527
+ }
7528
+ out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7529
+ }
7530
+
6845
7531
  // No "clear the trailing rows" pass is needed: composeFrame always returns
6846
7532
  // EXACTLY `rows` lines (both frames here share the same height, otherwise the
6847
7533
  // sizeChanged path above would have run), so a shrinking viewport is already
@@ -6918,8 +7604,8 @@ export function makeState({ cfg, session, opts }) {
6918
7604
  // was re-picked (setEffort is the only other writer, and it sets both).
6919
7605
  const effort = (session && session.effort) || cfg.effort || (cfg.reasoning ? 'on' : 'off');
6920
7606
  return {
6921
- // No model chosen yet: the interface says so instead of refusing to start.
6922
- modelMissing: !!(opts && opts.modelMissing),
7607
+ // No model chosen yet: the interface says so instead of refusing to start.
7608
+ modelMissing: !!(opts && opts.modelMissing),
6923
7609
  workspace: cwd,
6924
7610
  cfg,
6925
7611
  // The SAME session local this function already reads for `effort`, kept on the state
@@ -6962,6 +7648,8 @@ export function makeState({ cfg, session, opts }) {
6962
7648
  cwdOverride: null,
6963
7649
  tip: '',
6964
7650
  ctxPercent: 0,
7651
+ // Set once the endpoint reports a real context size; from then on the local estimate is not used.
7652
+ ctxReportedOnce: false,
6965
7653
  ctxTokens: 0,
6966
7654
  ctxMax: cfg.maxContextTokens || 512000,
6967
7655
  rounds: (session && session.rounds) || 0,
@@ -8483,7 +9171,9 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8483
9171
  // `dispatch` is module-level and cannot reach startTUI's `setCtxTokens`, so the expression is
8484
9172
  // repeated here rather than the call. Keep the two identical.
8485
9173
  const approx = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
8486
- state.ctxTokens = approx;
9174
+ // A resumed session carries the last REPORTED size, so the gauge starts where it left off
9175
+ // instead of at the estimate.
9176
+ if (!state.ctxReportedOnce) state.ctxTokens = approx;
8487
9177
  state.ctxMax = cfg.maxContextTokens || state.ctxMax;
8488
9178
  state.ctxPercent = usagePercent(approx, state.ctxMax);
8489
9179
 
@@ -8815,7 +9505,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8815
9505
  saveSession(session);
8816
9506
  // Same formula as everywhere else: messages PLUS the tool schemas.
8817
9507
  state.tokens = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
8818
- state.ctxTokens = state.tokens;
9508
+ if (!state.ctxReportedOnce) state.ctxTokens = state.tokens;
8819
9509
  state.ctxPercent = usagePercent(state.tokens, state.ctxMax || maxCtx);
8820
9510
  // The transcript was just replaced with a much shorter one. Drop the scroll
8821
9511
  // anchor (as /sessions and /move do) so the next paint seeds a fresh one and
@@ -10214,7 +10904,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10214
10904
  //
10215
10905
  // Plus the tool schemas, like every other writer: `/trim` counts messages, and the request also
10216
10906
  // carries the tools, so leaving them out dropped the gauge by that much more than was freed.
10217
- state.ctxTokens = after + (cfg.toolDefTokens || 0);
10907
+ if (!state.ctxReportedOnce) state.ctxTokens = after + (cfg.toolDefTokens || 0);
10218
10908
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || cfg.maxContextTokens || 1);
10219
10909
  if (session) {
10220
10910
  try {
@@ -12032,45 +12722,45 @@ function registerProvider(state, cfg, h, name, baseUrl, apiKey, protocol, typeLa
12032
12722
  fetchAndRegisterModels(state, cfg, name, h);
12033
12723
  }
12034
12724
 
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
- }
12725
+ async function fetchAndRegisterModels(state, cfg, providerName, h) {
12726
+ const p = (cfg.raw.providers || {})[providerName] || {};
12727
+ const baseUrl = p.base_url || p.baseUrl || '';
12728
+ if (!baseUrl) return;
12729
+ h.notice(`Discovering models for ${providerName}…`, 'info');
12730
+ const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
12731
+ // THE FETCH MUST SUCCEED BEFORE ANYTHING IS REMOVED.
12732
+ //
12733
+ // Deleting first and fetching second means an unreachable endpoint leaves the provider with no models at
12734
+ // all. Everything below runs only after a non-empty result.
12735
+ if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
12736
+
12737
+ cfg.raw.models = cfg.raw.models || {};
12738
+ // Snapshot what this provider had, so the merge below can keep fields the source does not report.
12739
+ const prevByKey = new Map();
12740
+ for (const key of Object.keys(cfg.raw.models)) {
12741
+ if (key.split('/')[0] !== providerName) continue;
12742
+ prevByKey.set(key, cfg.raw.models[key]);
12743
+ delete cfg.raw.models[key];
12744
+ }
12745
+
12746
+ // REPLACE THE LIST WITH WHAT WAS JUST FETCHED.
12747
+ //
12748
+ // A model the endpoint no longer reports disappears, and a renamed one does not appear twice. Merging
12749
+ // over the old entries kept both for ever, which is what made Alt+R look like it did nothing.
12750
+ const kept = [];
12751
+ for (const m of models) {
12752
+ const key = modelKey(providerName, m.id);
12753
+ // Merge over the SNAPSHOT: the live entries were just deleted, so reading them yields undefined and
12754
+ // the merge folds nothing — every field the source omits (context_length, pricing) would be wiped.
12755
+ const entry = mergeRefreshedModelEntry(providerName, m, prevByKey.get(key));
12756
+ cfg.raw.models[key] = entry;
12757
+ kept.push({ ...entry, id: m.id });
12758
+ }
12759
+ // Rewrite the provider's TOML blocks from that list. `addModel` appends one block per model and returns
12760
+ // early when one already exists, so calling it here left the removed entries on disk.
12761
+ try { replaceProviderModels(providerName, kept); } catch { /* the in-memory list is still correct */ }
12762
+ h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
12763
+ }
12074
12764
 
12075
12765
  // Merge ONE refreshed model's data over its previous config entry.
12076
12766
  //
@@ -12319,6 +13009,21 @@ export function tokenize(str) {
12319
13009
  }
12320
13010
  i += ku[0].length; continue;
12321
13011
  }
13012
+ // KITTY GRAPHICS REPLY. The terminal acknowledges an upload with its own APC:
13013
+ //
13014
+ // ESC _ G i=31;OK ESC \
13015
+ //
13016
+ // Nothing else consumes it, and an APC that reaches the key parser is ESC followed by ordinary
13017
+ // characters — so `Gi=31;OK` is typed into the composer, one letter at a time, every time the terminal
13018
+ // answers. Recognising it here and turning it into a token means it is CONSUMED: `handleKey` sees
13019
+ // `kittyReply` and ignores it.
13020
+ if (str[i + 1] === '_') {
13021
+ const end = str.indexOf('\x1b\\', i + 2);
13022
+ if (end === -1) break; // incomplete: wait for the rest
13023
+ out.push({ kittyReply: str.slice(i + 2, end) });
13024
+ i = end + 2;
13025
+ continue;
13026
+ }
12322
13027
  if (str[i + 1] === '[') {
12323
13028
  if (str[i + 2] === '<') {
12324
13029
  const mm = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(str.slice(i));
@@ -12383,21 +13088,24 @@ export function tokenize(str) {
12383
13088
  out.push({ key: base });
12384
13089
  i = j + 1;
12385
13090
  } else if (str[i + 1] === 'O') {
13091
+ // SS3: the application-cursor form of the arrows and function keys.
12386
13092
  const f = str[i + 2];
12387
13093
  if (f === undefined) break;
12388
- out.push({ key: f === 'P' ? 'f1' : 'escape' });
13094
+ const ss3 = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end',
13095
+ P: 'f1', Q: 'f2', R: 'f3', S: 'f4' }[f];
13096
+ out.push({ key: ss3 || 'escape' });
12389
13097
  i += 3;
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
- }
13098
+ } else {
13099
+ // `ESC <printable>` is Alt+that key. A second ESC stays a plain Escape.
13100
+ const altNext = str[i + 1];
13101
+ if (altNext && altNext >= '!' && altNext <= '~') {
13102
+ out.push({ key: 'alt-' + altNext.toLowerCase() });
13103
+ i += 2;
13104
+ continue;
13105
+ }
13106
+ out.push({ key: 'escape' });
13107
+ i++;
13108
+ }
12401
13109
  } else if (c === '\r') {
12402
13110
  out.push({ key: 'enter' });
12403
13111
  i++;
@@ -12999,7 +13707,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12999
13707
  return;
13000
13708
  }
13001
13709
  if (e.type === 'compacted') {
13002
- state.ctxTokens = e.after || state.ctxTokens;
13710
+ if (!state.ctxReportedOnce) state.ctxTokens = e.after || state.ctxTokens;
13003
13711
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
13004
13712
  // Compaction is over, so the Working row goes back to a rotating phrase.
13005
13713
  //
@@ -13037,7 +13745,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13037
13745
  if (e.type === 'data') {
13038
13746
  // A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
13039
13747
  // because the paint path cannot tell "slow" from "quiet" — only the events can.
13040
- state.lastTokenAt = Date.now();
13748
+ state.lastTokenAt = Date.now();
13041
13749
  // Feed the answer text into the paced buffer instead of painting it. The
13042
13750
  // buffer closes any open reasoning region itself, in arrival order, so the
13043
13751
  // block settles exactly when the provider moved on rather than when the next
@@ -13068,21 +13776,27 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13068
13776
  // stay identical.
13069
13777
  //
13070
13778
 
13071
- // THE ESTIMATE ONLY. No provider number is mixed in anywhere.
13779
+ // THE ESTIMATE FILLS IN, THE REPORT LEADS.
13072
13780
  //
13073
- // `e.tokens` is what this app's own tokenizer model prices the request at, and it is the ONE
13074
- // source for the gauge. `usage.input` was wired in twice and removed twice: the two count
13075
- // different things (a report covers the request that already went out, the estimate covers the
13076
- // one about to), so whichever ran last won and the number jumped between formulas.
13077
- state.ctxTokens = Number(e.tokens) || 0;
13078
- if (Number.isFinite(e.max) && e.max > 0) state.ctxMax = e.max;
13079
- state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
13080
- if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
13081
- if (state.tokenHistory[state.tokenHistory.length - 1] !== state.ctxTokens) {
13082
- state.tokenHistory.push(state.ctxTokens);
13083
- if (state.tokenHistory.length > 240) state.tokenHistory.splice(0, state.tokenHistory.length - 240);
13084
- }
13085
- if (session) session.ctxTokens = state.ctxTokens;
13781
+ // `context` prices the request ABOUT to go out; `usage` reports the one that just did. Letting
13782
+ // the estimate overwrite a fresh report made the number alternate between two definitions.
13783
+ // THE CONTEXT EVENT OWNS THE VISIBLE NUMBER.
13784
+ //
13785
+ // It prices the request about to go out, which is the only thing "how full is my context"
13786
+ // can mean. A provider report describes the request that ALREADY went out, so showing it
13787
+ // here makes the gauge answer a different question and alternate between two formulas.
13788
+ //
13789
+ // THE ESTIMATE IS ONLY FOR A TURN THAT HAS NEVER HAD A REPORT.
13790
+ //
13791
+ // It cannot be reconciled with the report and should not try: measured against this project's
13792
+ // endpoint, a 4 000-character message is 530 tokens and the local rule calls it 953 — the
13793
+ // estimate is systematically ~1.8× high. Letting it take over whenever it was larger put the
13794
+ // inflated figure back on every event, so the gauge read 600k while streaming and 500k when
13795
+ // idle: two formulas, and the visible one was the wrong one.
13796
+ //
13797
+ // `ctxReportedOnce` latches when a real figure arrives. After that the estimate never wins.
13798
+ // The decision lives in `setCtxTokens` so every writer obeys the same rule.
13799
+ if (!state.ctxReportedOnce) state.ctxTokens = Number(e.tokens) || 0;
13086
13800
  viewCur.renderSoon(); return;
13087
13801
  }
13088
13802
  if (e.type === 'usage') {
@@ -13103,20 +13817,43 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13103
13817
  // - on a cache hit some providers report only the uncached remainder, which made the gauge
13104
13818
  // COLLAPSE mid-turn and then jump back.
13105
13819
  //
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
- }
13820
+ // PROVIDER REPORT ARRIVES -> the estimate it covers is done.
13821
+ //
13822
+ // The counter is `settled + estimate`: finished steps plus the one streaming now. When a usage
13823
+ // report lands, `settled` grows by that step's exact figure AND `estimate` still holds the
13824
+ // same step's guess, so the row showed roughly double — measured 25 estimated + 25 settled
13825
+ // reading 50 — and every later step built on the inflated total.
13826
+ //
13827
+ // Clearing `estimate` here is what makes the estimate a LIVE figure for the step in flight
13828
+ // rather than a running sum that never forgets.
13829
+ if (e.usage && Number.isFinite(e.usage.output)) {
13830
+ state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
13831
+ state.turnOutputEstimate = 0;
13832
+ state.turnOutputTokens = state.turnOutputSettled;
13833
+ }
13834
+ if (e.usage && Number.isFinite(e.usage.input) && e.usage.input > 0) {
13835
+ // `input` is the whole prompt; `cached` breaks out how much of it was a cache hit. Measured:
13836
+ // a 4000-character message reported prompt_tokens 530 and cached_tokens 0 — adding the two
13837
+ // would count the cached part twice.
13838
+ const reported = e.usage.input;
13839
+ // THE PROVIDER REPORTS THE CONTEXT SIZE OUTRIGHT.
13840
+ //
13841
+ // Measured against this project's own endpoint: `prompt_tokens` is 31 for a six-token
13842
+ // message plus the system prompt and tools, and `prompt_tokens_details.cached_tokens` is
13843
+ // reported separately — so `prompt_tokens` is ALREADY the whole prompt. Adding the cached
13844
+ // figure again, as an earlier version did, double-counted it.
13845
+ //
13846
+ // That makes the local estimate unnecessary whenever this arrives, and having both is what
13847
+ // produced a gauge alternating between 150k and 300k: the estimate and the report describe
13848
+ // different requests and overwrote each other. The report wins and the estimate stands down.
13849
+ // Routed through `setCtxTokens`, which latches the switch and mirrors onto the session so a
13850
+ // resume starts from the reported size. Duplicating those fields here is what let a later
13851
+ // estimate overwrite them.
13852
+ state.ctxTokens = reported;
13853
+ state.ctxReportedOnce = true;
13854
+ state.ctxSource = 'provider';
13855
+ if (session) { session.ctxTokens = reported; session.ctxReportedOnce = true; }
13856
+ }
13120
13857
  refreshCost(state, cfg);
13121
13858
  viewCur.renderSoon(); return;
13122
13859
  }
@@ -13132,22 +13869,23 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13132
13869
  sessHost.countOutputTokens(estimateTokensFloat(e.text));
13133
13870
  viewCur.renderSoon(); return;
13134
13871
  }
13135
- if (e.type === 'aborted') {
13136
- // Show whatever the model had actually produced before the interrupt: the
13137
- // buffer is holding it, and an aborted turn is exactly when the user is
13138
- // reading to see how far it got.
13872
+ if (e.type === 'aborted' || e.type === 'stopped') {
13873
+ // A TURN CAN STOP WITHOUT THE REQUEST BEING ABORTED.
13874
+ //
13875
+ // `aborted` comes from the LLM stream; `stopped` comes from the agent loop after a tool
13876
+ // finished with `stopRequested` set — which is exactly what Esc does while a Bash command is
13877
+ // running. The TUI handled only `aborted`, so killing a long command left the `Using Bash` row
13878
+ // pulsing and printed no notice: the interrupt worked and looked like it had not.
13139
13879
  sessHost.flushStream();
13140
13880
  const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
13141
13881
  if (t) t.pending = false;
13142
- // Settle any RUNNING tool rows too. A tool that was mid-flight when the
13143
- // user hit Esc never gets its tool_result, so its `pending` stayed true
13144
- // and the `Using <Tool>` name kept pulsing forever. Clearing it drops the
13145
- // name to plain cyan immediately.
13882
+ // Settle any RUNNING tool rows too. A tool interrupted mid-flight never gets its result, so
13883
+ // `pending` stayed true and the name kept pulsing forever.
13884
+ let interruptedTool = false;
13146
13885
  for (const m of state.chat) {
13147
- if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; }
13886
+ if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; interruptedTool = true; }
13148
13887
  }
13149
- // Use a special role that won't show the ✓ icon
13150
- state.chat.push({ role: 'aborted', text: C.red + 'interrupted' + C.reset });
13888
+ state.chat.push({ role: 'aborted', text: C.red + (interruptedTool ? 'interrupted (command stopped)' : 'interrupted') + C.reset });
13151
13889
  viewCur.render();
13152
13890
  return;
13153
13891
  }
@@ -13194,7 +13932,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13194
13932
  // Track the key argument as it streams so "Using Bash (cmd)" shows
13195
13933
  // the command DURING the run rather than only once it has finished.
13196
13934
  entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
13197
- entry._cache = null; // the argument is part of the rendered line
13935
+ entry._cache = null; // the argument is part of the rendered line
13198
13936
  if (entry.toolName === 'Write') {
13199
13937
  entry.streamContent = argStreamValue(entry._argStream, 'content');
13200
13938
  // Incremental line count so the preview's "… N more lines" note is
@@ -13554,7 +14292,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13554
14292
 
13555
14293
  // Same formula as everywhere else: messages PLUS the tool schemas.
13556
14294
  const approx = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
13557
- state.ctxTokens = approx;
14295
+ if (!state.ctxReportedOnce) state.ctxTokens = approx;
13558
14296
  state.ctxMax = cfg.maxContextTokens || state.ctxMax;
13559
14297
  state.ctxPercent = usagePercent(approx, state.ctxMax);
13560
14298
  // `session.messages` now holds the full request, system prompt included, so the bar
@@ -13710,40 +14448,53 @@ export async function startTUI(opts) {
13710
14448
  * Keeping one entry point is also what makes the number honest: there is exactly one definition of
13711
14449
  * "how much context is in use", so a future caller cannot invent a third.
13712
14450
  */
13713
- function setCtxTokens(tokens, max) {
14451
+ /**
14452
+ * The one place the context gauge is set.
14453
+ *
14454
+ * `opts.fromEstimate` marks a number the local tokenizer produced. Those are only used until the
14455
+ * endpoint reports a real size — measured against it, the estimate runs ~1.8x high (a 4 000-character
14456
+ * message is 530 tokens, the estimate says 953), so once a report exists the estimate is ignored and
14457
+ * the gauge stays on the reported figure instead of jumping between the two.
14458
+ *
14459
+ * `opts.reported` marks a provider figure; it latches the switch and mirrors onto the session so a
14460
+ * resume starts from the real size rather than the estimate.
14461
+ */
14462
+ function setCtxTokens(tokens, max, opts = {}) {
14463
+ if (opts.fromEstimate && state.ctxReportedOnce) return; // a report outranks every estimate
13714
14464
  const n = Math.max(0, Number(tokens) || 0);
13715
14465
  state.ctxTokens = n;
14466
+ if (opts.reported) {
14467
+ state.ctxReportedOnce = true;
14468
+ state.ctxSource = 'provider';
14469
+ if (session) { session.ctxTokens = n; session.ctxReportedOnce = true; }
14470
+ } else if (opts.fromEstimate) {
14471
+ state.ctxSource = 'estimate';
14472
+ }
13716
14473
  if (Number.isFinite(max) && max > 0) state.ctxMax = max;
13717
14474
  state.ctxPercent = usagePercent(n, state.ctxMax || 1);
13718
- // The trend /usage draws. Bounded, because a long session would otherwise grow it without limit.
13719
14475
  if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
13720
14476
  const h = state.tokenHistory;
13721
- if (h[h.length - 1] !== n) h.push(n);
13722
- if (h.length > 240) h.splice(0, h.length - 240);
13723
- // Mirrored on the session so saveSession captures it and --resume restores the chart.
13724
- if (session) {
13725
- session.ctxTokens = n;
13726
- if (!Array.isArray(session.tokenHistory)) session.tokenHistory = [];
13727
- if (session.tokenHistory[session.tokenHistory.length - 1] !== n) session.tokenHistory.push(n);
13728
- if (session.tokenHistory.length > 240) session.tokenHistory.splice(0, session.tokenHistory.length - 240);
14477
+ if (h[h.length - 1] !== n) {
14478
+ h.push(n);
14479
+ if (h.length > 240) h.splice(0, h.length - 240);
13729
14480
  }
13730
14481
  }
13731
14482
 
13732
14483
 
13733
- // Set when the config has no model, so the interface can say so without blocking startup.
13734
- let modelMissing = false;
13735
-
14484
+ // Set when the config has no model, so the interface can say so without blocking startup.
14485
+ let modelMissing = false;
14486
+
13736
14487
  // FIRST: Ensure model is initialized before restoring session
13737
14488
  if (!cfg.model || !cfg.innerModel) {
13738
14489
  // Model not set yet - use default or prompt user
13739
14490
  if (!cfg.model) {
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;
14491
+ // NOT a return: a fresh install has no model yet, and the advice is `/model` — which needs the
14492
+ // prompt this function is about to build. Flagged instead, and reported once the TUI is up.
14493
+ modelMissing = true;
13743
14494
  }
13744
14495
  }
13745
14496
 
13746
- const state = makeState({ cfg, session, opts: { ...opts, modelMissing } });
14497
+ const state = makeState({ cfg, session, opts: { ...opts, modelMissing } });
13747
14498
  // Mirror the session on the state so `/move` can rebind ONE binding and have every
13748
14499
  // consumer follow (see `replaceSession`).
13749
14500
  state.session = session;
@@ -14259,7 +15010,7 @@ export async function startTUI(opts) {
14259
15010
  }
14260
15011
  let lastFrame = null;
14261
15012
  let paintScheduled = false;
14262
- let lastPaintAt = 0; // when the last frame actually went to the terminal
15013
+ let lastPaintAt = 0; // when the last frame actually went to the terminal
14263
15014
  // Deadline for the next FULL repaint. A differential painter compares against the frame
14264
15015
  // it BELIEVES it painted, never the real screen, so any divergence between the two is
14265
15016
  // permanent: it survives every later frame because the comparison keeps saying "that row
@@ -14288,13 +15039,49 @@ export async function startTUI(opts) {
14288
15039
  const forcePaint = state._forceRepaint === true;
14289
15040
  if (forcePaint) state._forceRepaint = false;
14290
15041
  const forceFull = forcePaint || (now - lastFullPaint) >= FULL_REPAINT_MS;
14291
- const out = diffFrame(forceFull ? null : lastFrame, frame);
15042
+ // ONLY A PAINT WITH NO PREVIOUS FRAME NEEDS THE BITMAPS AGAIN.
15043
+ //
15044
+ // `forceFull` fires once a second to repair drift, and it passes `null` as the previous frame. That is
15045
+ // not the same as a first paint: the terminal is still displaying every image. Re-sending one makes
15046
+ // Windows Terminal move the screen, and the next paint then draws the interface at the new offset —
15047
+ // which is what left the composer repeated down the page.
15048
+ const firstPaint = lastFrame === null;
15049
+ // THE CELL-GRID PAINTER.
15050
+ //
15051
+ // `forceFull` no longer means "throw away the previous frame". It means "the terminal may have drifted",
15052
+ // which is handled by re-sending bitmaps only: the text cells are compared every frame regardless, so a
15053
+ // full text repaint was never buying anything except the bitmap churn that shredded the screen.
15054
+ const resendImages = firstPaint || forcePaint;
15055
+ const out = paintCellFrame(frame, resendImages);
14292
15056
  if (forceFull) lastFullPaint = now;
14293
15057
  lastFrame = frame;
14294
15058
  state._hitboxes = frame.hitboxes || [];
14295
15059
  state._composerMeta = frame.composerMeta || [];
14296
- if (out) stdout.write(out);
14297
- lastPaintAt = now;
15060
+ if (out) {
15061
+ if (process.env.HNCODE_TRACE_PAINT) {
15062
+ try {
15063
+ const sixels = (out.match(/\x1bPq/g) || []).length;
15064
+ const rows = [...out.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
15065
+ const dir = path.join(process.env.USERPROFILE || process.env.HOME || '.', '.hncode');
15066
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
15067
+ fs.appendFileSync(path.join(dir, 'paint.log'), JSON.stringify({
15068
+ at: Date.now(),
15069
+ forceFull,
15070
+ bytes: out.length,
15071
+ sixels,
15072
+ rows: rows.length ? rows.join(',') : '',
15073
+ placements: [...out.matchAll(/\x1b\[\?80l\x1b\[(\d+);1H[\s\S]*?"1;1;(\d+);(\d+)/g)]
15074
+ .map((m) => ({ row: Number(m[1]), w: Number(m[2]), h: Number(m[3]) })),
15075
+ head: JSON.stringify(out.slice(0, 160)),
15076
+ tail: JSON.stringify(out.slice(-80)),
15077
+ newlines: (out.match(/\n/g) || []).length,
15078
+ height: dims().rows,
15079
+ }) + '\n');
15080
+ } catch { /* tracing must never break a paint */ }
15081
+ }
15082
+ stdout.write(out);
15083
+ }
15084
+ lastPaintAt = now;
14298
15085
  // "Painted" becomes observable here: everything that reaches the screen goes
14299
15086
  // through this one function, so the tick advancing means bytes left this process.
14300
15087
  // See src/flush-tick.js for what reads it.
@@ -14341,26 +15128,26 @@ export async function startTUI(opts) {
14341
15128
  */
14342
15129
  function countOutputTokens(n) { return sessHost.countOutputTokens(n); }
14343
15130
 
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
- }
15131
+ /**
15132
+ * The render rate this session is capped at, from `/settings` → Render FPS.
15133
+ *
15134
+ * Read on every call rather than cached: the setting can change mid-session, and a cached rate would
15135
+ * only take effect after a restart.
15136
+ */
15137
+ function renderFps() {
15138
+ const v = Number(cfg && cfg.render_fps);
15139
+ // A terminal cannot render at 0Hz; the lowest meaningful rate is one frame a second.
15140
+ return Number.isFinite(v) ? Math.max(1, Math.round(v)) : 30;
15141
+ }
15142
+
15143
+ function renderSoon() {
15144
+ if (paintScheduled) return;
15145
+ const budget = Math.max(1, Math.round(1000 / renderFps()));
15146
+ const wait = Math.max(0, (lastPaintAt || 0) + budget - Date.now());
15147
+ paintScheduled = true;
15148
+ if (wait === 0) { setImmediate(paintNow); return; }
15149
+ setTimeout(paintNow, wait);
15150
+ }
14364
15151
  function openPicker(p) {
14365
15152
  let footer = p.footer ? { focused: false, ...p.footer } : null;
14366
15153
  if (p.footerFor) {
@@ -15470,7 +16257,7 @@ export async function startTUI(opts) {
15470
16257
  ],
15471
16258
  resolve,
15472
16259
  };
15473
- renderSoon();
16260
+ renderSoon();
15474
16261
  }),
15475
16262
  }))
15476
16263
  .then((res) => {
@@ -16614,6 +17401,11 @@ export async function startTUI(opts) {
16614
17401
  }
16615
17402
 
16616
17403
  function handleKey(t) {
17404
+ // A KITTY GRAPHICS REPLY IS NOT A KEYSTROKE. It is the terminal acknowledging an upload (`G i=31;OK`) or
17405
+ // reporting that it dropped an image. The token exists so the bytes are CONSUMED rather than typed into the
17406
+ // composer; there is nothing to act on yet, because a failed upload is handled by placing the formula as
17407
+ // text next time rather than by retrying mid-frame.
17408
+ if (t && t.kittyReply !== undefined) return;
16617
17409
  // USER KEYBINDINGS, their own layer. A binding either REPLACES a key with another key
16618
17410
  // (applied by rewriting `t.key` below, so the whole switch sees the bound key without
16619
17411
  // knowing bindings exist) or runs a slash command. Mouse and wheel events are never
@@ -16950,7 +17742,7 @@ export async function startTUI(opts) {
16950
17742
  // its own arrow behaviour on the un-modified keys.
16951
17743
  if (t.key === 'c-up' || t.key === 'c-down') {
16952
17744
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16953
- scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
17745
+ scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
16954
17746
  renderFrame();
16955
17747
  return;
16956
17748
  }
@@ -16959,21 +17751,21 @@ export async function startTUI(opts) {
16959
17751
  // Ctrl+L — expand / collapse the plugin log block(s). The last one is the
16960
17752
  // useful target (it is the most recent), but every pluginLog message is toggled
16961
17753
  // 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
- }
17754
+ if (t.key === 'alt-r') {
17755
+ // Only when nothing owns the keyboard: a picker or a dialog has its own meaning for R.
17756
+ if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
17757
+ const current = (cfg && cfg.provider) || '';
17758
+ if (!current) {
17759
+ notice('No provider selected — use /model to pick one.', 'error');
17760
+ } else {
17761
+ // Fire and forget: the notice reports the outcome, and blocking the key handler on a network
17762
+ // round-trip would freeze input for as long as the endpoint takes.
17763
+ void fetchAndRegisterModels(state, cfg, current, { notice: (m, k) => notice(m, k) });
17764
+ }
17765
+ renderFrame();
17766
+ return;
17767
+ }
17768
+ }
16977
17769
  if (t.key === 'c-l') {
16978
17770
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16979
17771
  const logs = (state.chat || []).filter((m) => m && m.pluginLog);
@@ -17220,25 +18012,36 @@ export async function startTUI(opts) {
17220
18012
  renderFrame(); return;
17221
18013
  }
17222
18014
  if (t.key === 'up' || t.key === 'down') {
17223
- // ↑/↓ while the agent streams: FIRST give the composer its caret move
17224
- // (a multi-line draft must still navigate), then fall back to scrolling
17225
- // ONLY when there is nothing else to do. The scroll is what made ↑ feel
17226
- // stuck while output streamed, so it is now the last resort.
17227
18015
  const cur = state.input || '';
17228
- if (cur === '') {
17229
- // Empty composer: the key belongs to HISTORY, not to the viewport.
17230
- // With no history the key does nothing at all — silently scrolling
17231
- // the transcript is what made a bare ↑ feel like it ate the key.
17232
- if (state.history.length) {
17233
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
17234
- state.historyIdx = t.key === 'up'
17235
- ? Math.max(0, state.historyIdx - 1)
17236
- : Math.min(state.history.length, state.historyIdx + 1);
17237
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
18016
+ const hist = Array.isArray(state.history) ? state.history : [];
18017
+ const single = cur.indexOf('\n') === -1;
18018
+ if (hist.length) {
18019
+ // ↑ ENTERS HISTORY from anywhere in a ONE-LINE draft, and from the very start of a
18020
+ // multi-line one, where the caret has somewhere else to go.
18021
+ if (t.key === 'up' && state.historyIdx === -1 && (single || (state.caret || 0) === 0)) {
18022
+ // Keep what is in the composer so ↓ can bring it back.
18023
+ state.draftInput = cur;
18024
+ state.historyIdx = hist.length - 1;
18025
+ state.input = hist[state.historyIdx] || '';
17238
18026
  state.caret = state.input.length;
17239
18027
  refreshMenu(state); renderFrame();
18028
+ return;
18029
+ }
18030
+ if (state.historyIdx >= 0) {
18031
+ if (t.key === 'up') {
18032
+ if (state.historyIdx === 0) return; // oldest entry: the caret keys below take over
18033
+ state.historyIdx -= 1;
18034
+ state.input = hist[state.historyIdx] || '';
18035
+ } else {
18036
+ const next = state.historyIdx + 1;
18037
+ // Past the newest entry the DRAFT returns, not an empty line.
18038
+ state.input = next >= hist.length ? (state.draftInput || '') : (hist[next] || '');
18039
+ state.historyIdx = next >= hist.length ? -1 : next;
18040
+ }
18041
+ state.caret = state.input.length;
18042
+ refreshMenu(state); renderFrame();
18043
+ return;
17240
18044
  }
17241
- return;
17242
18045
  }
17243
18046
  const insideW = Math.max(0, dims().cols - 2);
17244
18047
  const layout = composerLayout(state, insideW - 3);
@@ -17279,16 +18082,33 @@ export async function startTUI(opts) {
17279
18082
  return;
17280
18083
  }
17281
18084
  // First/last row (or an empty composer): this is history's turn.
18085
+ // This mirrors the streaming branch above exactly — the two used to differ, and the difference
18086
+ // was the bug: here a lone ↓ could re-enter the walk at the newest entry instead of resting at
18087
+ // the draft, so ↓ alternated between the last history entry and an empty box forever.
17282
18088
  if (state.history.length) {
17283
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
17284
- state.historyIdx = t.key === 'up'
17285
- ? Math.max(0, state.historyIdx - 1)
17286
- : Math.min(state.history.length, state.historyIdx + 1);
17287
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
18089
+ if (t.key === 'up' && state.historyIdx === -1) {
18090
+ // Entering history: keep the composer so ↓ can bring it back.
18091
+ state.draftInput = cur;
18092
+ state.historyIdx = state.history.length - 1;
18093
+ state.input = state.history[state.historyIdx] || '';
18094
+ } else if (state.historyIdx >= 0) {
18095
+ if (t.key === 'up') {
18096
+ if (state.historyIdx === 0) return; // oldest entry: the caret keys take over
18097
+ state.historyIdx -= 1;
18098
+ state.input = state.history[state.historyIdx] || '';
18099
+ } else {
18100
+ const next = state.historyIdx + 1;
18101
+ // Past the newest entry the DRAFT returns, and the index goes to -1 so the next ↓ rests.
18102
+ state.input = next >= state.history.length ? (state.draftInput || '') : (state.history[next] || '');
18103
+ state.historyIdx = next >= state.history.length ? -1 : next;
18104
+ }
18105
+ } else {
18106
+ return; // not browsing and not ↑: the key is not ours
18107
+ }
17288
18108
  state.caret = state.input.length;
17289
18109
  refreshMenu(state); renderFrame();
18110
+ return;
17290
18111
  }
17291
- return;
17292
18112
  }
17293
18113
  }
17294
18114
  }
@@ -17650,14 +18470,23 @@ export async function startTUI(opts) {
17650
18470
  if (t.key === 'up' || t.key === 'down') {
17651
18471
  const cur = state.input || '';
17652
18472
  if (cur === '') {
17653
- // Empty composer: history owns the key. With NO history the key does
17654
- // nothing — it must not silently scroll the transcript.
18473
+ // Empty composer: history owns the key. With no history it does nothing, rather than silently
18474
+ // scrolling the transcript.
17655
18475
  if (state.history.length) {
17656
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
17657
- state.historyIdx = t.key === 'up'
17658
- ? Math.max(0, state.historyIdx - 1)
17659
- : Math.min(state.history.length, state.historyIdx + 1);
17660
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
18476
+ if (state.historyIdx === -1) {
18477
+ if (t.key === 'down') return; // already at the draft: nothing older to reach
18478
+ state.draftInput = cur;
18479
+ state.historyIdx = state.history.length - 1;
18480
+ state.input = state.history[state.historyIdx] || '';
18481
+ } else if (t.key === 'up') {
18482
+ if (state.historyIdx === 0) return; // oldest entry
18483
+ state.historyIdx -= 1;
18484
+ state.input = state.history[state.historyIdx] || '';
18485
+ } else {
18486
+ const next = state.historyIdx + 1;
18487
+ state.input = next >= state.history.length ? (state.draftInput || '') : (state.history[next] || '');
18488
+ state.historyIdx = next >= state.history.length ? -1 : next;
18489
+ }
17661
18490
  state.caret = state.input.length;
17662
18491
  state.composerSel = null;
17663
18492
  refreshMenu(state); renderFrame();
@@ -17685,11 +18514,20 @@ export async function startTUI(opts) {
17685
18514
  // for the key, so it becomes HISTORY. (Scrolling the transcript is still
17686
18515
  // available on PgUp/PgDn and the wheel — the arrows belong to the input.)
17687
18516
  if (state.history.length) {
17688
- if (state.historyIdx === -1) state.historyIdx = state.history.length;
17689
- state.historyIdx = t.key === 'up'
17690
- ? Math.max(0, state.historyIdx - 1)
17691
- : Math.min(state.history.length, state.historyIdx + 1);
17692
- state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
18517
+ if (state.historyIdx === -1) {
18518
+ if (t.key === 'down') return; // already at the draft
18519
+ state.draftInput = cur;
18520
+ state.historyIdx = state.history.length - 1;
18521
+ state.input = state.history[state.historyIdx] || '';
18522
+ } else if (t.key === 'up') {
18523
+ if (state.historyIdx === 0) return; // oldest entry
18524
+ state.historyIdx -= 1;
18525
+ state.input = state.history[state.historyIdx] || '';
18526
+ } else {
18527
+ const next = state.historyIdx + 1;
18528
+ state.input = next >= state.history.length ? (state.draftInput || '') : (state.history[next] || '');
18529
+ state.historyIdx = next >= state.history.length ? -1 : next;
18530
+ }
17693
18531
  state.caret = state.input.length;
17694
18532
  state.composerSel = null;
17695
18533
  refreshMenu(state); renderFrame(); return;
@@ -17697,13 +18535,13 @@ export async function startTUI(opts) {
17697
18535
  return;
17698
18536
  }
17699
18537
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
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;
18538
+ // HOW HARD THE USER IS TURNING, from the interval between key events.
18539
+ //
18540
+ // One rate cannot serve both gestures: a fixed one row makes a fast spin crawl, a fixed three jumps
18541
+ // past what a careful reader wanted. The interval between events is the speed, and it is already
18542
+ // in the stream — no timer, no separate gesture detection. Slow: one row. Fast: up to eight.
18543
+ const step = wheelStep(state, "wheel");
18544
+ const d = t.key === 'wheelup' ? step : -step;
17707
18545
  if (state.panel) {
17708
18546
  const p = state.panel;
17709
18547
  p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
@@ -18067,26 +18905,26 @@ export async function startTUI(opts) {
18067
18905
  // Read live, so a /external flip applies to the very next tool call.
18068
18906
  function externalAllowed() { return sessHost.externalAllowed(); }
18069
18907
 
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
-
18908
+ /**
18909
+ * How many rows one notch or keypress should move, from how fast they are arriving.
18910
+ *
18911
+ * Shared by the wheel and by Ctrl+Up/Down so the two cannot drift: they are the same gesture with
18912
+ * different hardware. The EVENT STREAM is the speed sensor — a wheel turned slowly sends notches far
18913
+ * apart and one spun sends them in a burst, and a key held down repeats at the OS repeat rate.
18914
+ *
18915
+ * `family` keeps the two clocks apart: alternating a wheel notch with a Ctrl+Up should not be read as one
18916
+ * burst of either. It also makes the FIRST event of a family one row — `Infinity` lands in the slowest
18917
+ * step, so nothing jumps on the first touch after a pause.
18918
+ */
18919
+ function wheelStep(state, family) {
18920
+ const now = Date.now();
18921
+ const at = state._stepAt || (state._stepAt = {});
18922
+ const since = at[family] ? now - at[family] : Infinity;
18923
+ at[family] = now;
18924
+ // 140ms is about the gap between deliberate notches; under 55ms they are arriving in a burst.
18925
+ return since > 140 ? 1 : since > 90 ? 2 : since > 55 ? 4 : 8;
18926
+ }
18927
+
18090
18928
  function scrollChat(state, delta) {
18091
18929
  const cols = dims().cols, rows = dims().rows;
18092
18930
  // Only the TOTAL row count is needed to clamp the scroll position. This used to
@@ -18120,6 +18958,8 @@ function wheelStep(state, family) {
18120
18958
  state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
18121
18959
  state.mentionOffset = 0;
18122
18960
  state.historyIdx = -1;
18961
+ // The draft belongs to the history walk that just ended; the next ↑ starts fresh.
18962
+ state.draftInput = '';
18123
18963
  if (!text) { renderFrame(); return; }
18124
18964
  // `@name message` sends STRAIGHT to a teammate, without the model.
18125
18965
  //
@@ -18346,42 +19186,42 @@ function wheelStep(state, family) {
18346
19186
  // means two requests and a round trip to learn an id that this side invents anyway. Passing
18347
19187
  // `new: true` lets the daemon accept a prompt against any session URL and have it run in a new
18348
19188
  // 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
- };
19189
+ hub.actions.submit = async (text, opts) => {
19190
+ if (opts && opts.new) {
19191
+ // Flush the outgoing session first: its todos and transcript belong to the id being replaced,
19192
+ // and `dispatch` does exactly what `/new` does, so the two cannot drift apart.
19193
+ await core.dispatch('new', '');
19194
+ // The hub's rows and event ring belong to the session that just ended. Clearing them keeps a
19195
+ // viewer from showing the previous conversation under the new id.
19196
+ hub.rows = [];
19197
+ hub.events = [];
19198
+ }
19199
+ // `core.submit` AWAITS THE WHOLE TURN, so its result is the finished turn — no polling needed.
19200
+ const out = await core.submit(text);
19201
+ // `opts.return` asks for the transcript of what just ran:
19202
+ //
19203
+ // "turn" the rows of this turn, tool results included
19204
+ // "full" every row the session holds
19205
+ // "none" (default) whatever `core.submit` returned, unchanged
19206
+ //
19207
+ // Without this a caller had to submit, wait an unknown time, then issue a second request for
19208
+ // `/api/state` — two round trips and a guess at how long the turn would take.
19209
+ const want = opts && opts.return;
19210
+ if (!want || want === 'none') return out;
19211
+ // FORCE ONE SYNC: `syncChat` does nothing while no browser is subscribed, so `hub.rows` would be
19212
+ // empty here and the caller would get an empty transcript from a turn that plainly ran.
19213
+ try { hub.syncChat(state.chat || []); } catch { /* the reply still carries `result` */ }
19214
+ // The turn begins at the last user row; everything after it is what the prompt produced.
19215
+ const rows = Array.isArray(hub.rows) ? hub.rows : [];
19216
+ let from = 0;
19217
+ for (let k = rows.length - 1; k >= 0; k--) { if (rows[k] && rows[k].role === 'user') { from = k; break; } }
19218
+ const slice = want === 'full' ? rows : rows.slice(from);
19219
+ const detail = (opts && opts.detail) || 'full';
19220
+ const shaped = detail === 'minimal' ? slice.filter((r) => r.role !== 'tool')
19221
+ : detail === 'summary' ? slice.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r))
19222
+ : slice;
19223
+ return { result: out, rows: shaped, detail, sessionId: session && session.id };
19224
+ };
18385
19225
  hub.actions.dispatch = async (cmd, arg) => {
18386
19226
  const out = await core.dispatch(cmd, arg);
18387
19227
  // The core runs the command; the repaint is this front end's business, so it stays
@@ -19390,14 +20230,18 @@ function quit() {
19390
20230
  process.exit(1);
19391
20231
  });
19392
20232
  process.on('unhandledRejection', (reason) => {
20233
+ // REPORT AND CARRY ON. A rejected background task (a failed update check, a refused model refresh,
20234
+ // a clipboard read) must not end the session: those callers are fire-and-forget by design, and the
20235
+ // turn loop reports its own failures as events. Exiting here turned any one of them into a lost
20236
+ // conversation.
19393
20237
  try { sess.saveSession(session); } catch {}
19394
- // The STACK is the whole point: the message alone ("...of undefined") does not
19395
- // say which line. console.error() writes into the alternate screen, which the
19396
- // exit handler then wipes, so the user never sees it — the log is the only
19397
- // place the trace survives.
19398
- noteExit(`unhandledRejection: ${(reason && reason.message) || reason}\n${(reason && reason.stack) || ''}`);
19399
- console.error('\nhncode unhandled rejection:', reason);
19400
- process.exit(1);
20238
+ const msg = (reason && reason.message) || String(reason);
20239
+ const stack = (reason && reason.stack) || '';
20240
+ // The log is the only place the trace survives: stderr writes into the alternate screen, which the
20241
+ // exit handler wipes.
20242
+ noteExit(`unhandledRejection: ${msg}\n${stack}`);
20243
+ // Visible in the transcript rather than on a screen that is about to be cleared.
20244
+ try { notice(`background task failed: ${msg}`, 'error'); } catch {}
19401
20245
  });
19402
20246
 
19403
20247
  // REMOTE CONTROL (--control): expose a local socket so a script can queue a