@hncode/hncode 0.5.6 → 0.6.1

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 (59) hide show
  1. package/package.json +2 -3
  2. package/src/agent.js +67 -2
  3. package/src/clipboard.js +221 -33
  4. package/src/image-row.js +111 -0
  5. package/src/latex.js +1557 -0
  6. package/src/llm.js +19 -4
  7. package/src/mermaid/ansi.js +55 -0
  8. package/src/mermaid/canvas.js +449 -0
  9. package/src/mermaid/class-style.js +57 -0
  10. package/src/mermaid/css-colors.js +153 -0
  11. package/src/mermaid/diagrams/class.js +289 -0
  12. package/src/mermaid/diagrams/er.js +201 -0
  13. package/src/mermaid/diagrams/flowchart.js +488 -0
  14. package/src/mermaid/diagrams/gitgraph.js +205 -0
  15. package/src/mermaid/diagrams/mindmap.js +101 -0
  16. package/src/mermaid/diagrams/pie.js +104 -0
  17. package/src/mermaid/diagrams/sequence.js +282 -0
  18. package/src/mermaid/diagrams/state.js +292 -0
  19. package/src/mermaid/diagrams/timeline.js +99 -0
  20. package/src/mermaid/graph.js +138 -0
  21. package/src/mermaid/index.js +59 -0
  22. package/src/mermaid/labels.js +339 -0
  23. package/src/mermaid/layout-seq.js +225 -0
  24. package/src/mermaid/layout.js +1454 -0
  25. package/src/mermaid/registry.js +54 -0
  26. package/src/mermaid/source-box.js +102 -0
  27. package/src/mermaid/statements.js +235 -0
  28. package/src/mermaid/types.js +1 -0
  29. package/src/mermaid/width.js +29 -0
  30. package/src/screen-diff.js +512 -0
  31. package/src/screen-paint.js +128 -0
  32. package/src/screen.js +277 -0
  33. package/src/subagent-types.js +136 -147
  34. package/src/term-caps.js +10 -4
  35. package/src/tex/font.js +292 -0
  36. package/src/tex/fonts/LICENSE.txt +11 -0
  37. package/src/tex/fonts/STIX2Math.ttf +0 -0
  38. package/src/tex/index.js +293 -0
  39. package/src/tex/kitty.js +186 -0
  40. package/src/tex/layout.js +446 -0
  41. package/src/tex/parse.js +331 -0
  42. package/src/tex/raster.js +216 -0
  43. package/src/tex/render.js +167 -0
  44. package/src/tex/sixel.js +326 -0
  45. package/src/tex/svg.js +237 -0
  46. package/src/tool-result.js +2 -2
  47. package/src/tools/agent-swarm.js +3 -15
  48. package/src/tools/agent.js +454 -464
  49. package/src/tools/ask-user-question.js +195 -206
  50. package/src/tools/bash.js +39 -3
  51. package/src/tools/edit.js +10 -112
  52. package/src/tools/fetch-url.js +130 -120
  53. package/src/tools/goal.js +93 -0
  54. package/src/tools/index.js +2 -0
  55. package/src/tools/memory.js +80 -88
  56. package/src/tools/read.js +27 -23
  57. package/src/tools/todo.js +8 -13
  58. package/src/tools/validate-args.js +78 -0
  59. package/src/tui.js +1718 -585
package/src/tui.js CHANGED
@@ -20,7 +20,7 @@ import cp from 'node:child_process';
20
20
  import {
21
21
  C, TRUECOLOR, blockCursor, showCursor, hideCursor, alternateScreen, clearScreen, clearAndSetBg, setTheme, setLightBackground, currentTheme, THEME_NAMES, THEME_LABELS, isLightTheme, hasTheme, lerpColor, bgRgb,
22
22
  } from './colors.js';
23
- import { copyText, readText, warmClipboard, readClipboardContentAsync, cleanupPastedImages, stopClipboardHelper } from './clipboard.js';
23
+ import { copyText, readText, warmClipboard, readClipboardContentAsync, readClipboardTextOnly, imageUrlFromClipboardText, cleanupPastedImages, stopClipboardHelper } from './clipboard.js';
24
24
  import { visualWidth, estimateTokens, estimateTokensFloat, estimateMessagesTokens, estimateMessageTokens, messageTokens, expandTabs } from './term.js';
25
25
  // The picker is composited onto the frame as a cell layer rather than assigned over
26
26
  // The picker is composited onto the frame as a cell layer rather than assigned over
@@ -58,7 +58,15 @@ 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';
69
+ import { installGoalStore, getGoal } from './tools/goal.js';
62
70
  import { decideFromRules, describeRules, selfTestRules, describeSelfTest } from './permissions.js';
63
71
  import { isDestructiveCommand as shellIsDestructiveCommand, isReadOnlyCommand as shellIsReadOnlyCommand } from './shell-safety.js';
64
72
  import { usdRate, syncRate, convert, normalizeCurrency, SUPPORTED_CURRENCIES } from './fx.js';
@@ -427,7 +435,7 @@ export const TIPS = [
427
435
  'Queued messages wait for the turn to finish',
428
436
  '/steer injects a message into the running turn',
429
437
  'Ctrl+R reloads the current session from disk',
430
- 'Alt+R re-fetches the provider\'s model list',
438
+ 'Alt+R re-fetches the provider\'s model list',
431
439
  '/help shows available commands at any time',
432
440
  '/undo rolls back the transcript AND the files',
433
441
  '/fork copies the session so you can branch',
@@ -552,8 +560,8 @@ export function reducedMotionOn(state) {
552
560
  * tool, a thinking preview) gets the same treatment from one place.
553
561
  */
554
562
  export function spinnerFrame(state, tick) {
555
- if (reducedMotionOn(state)) {
556
- const t = tick !== undefined ? tick : (state && state.spin) || 0;
563
+ if (reducedMotionOn(state)) {
564
+ const t = tick !== undefined ? tick : (state && state.spin) || 0;
557
565
  return { glyph: REDUCED_MOTION_DOT, dim: Math.floor(t / (REDUCED_MOTION_CYCLE_MS / 80)) % 2 === 1 };
558
566
  }
559
567
  const n = SPINNER.length;
@@ -647,7 +655,7 @@ export function sweepPulse(dist, half, edge = 'cosine', flat = 0) {
647
655
  * glint is a point travelling along the row, not a wash sweeping over it. It stops at one rather than
648
656
  * shrinking to zero because a two-character row still needs something to see.
649
657
  */
650
- export const LIGHT_FRACTION = 1 / 4;
658
+ export const LIGHT_FRACTION = 1 / 4;
651
659
 
652
660
  /**
653
661
  * Milliseconds per unit of `state.spin`.
@@ -679,7 +687,7 @@ export function lightWidth(n) {
679
687
  * palettes: the peak moves one letter at a time, and the row reads as a lamp travelling rather than as a
680
688
  * single pixel flashing.
681
689
  */
682
- export const TAIL_CHARS = 4;
690
+ export const TAIL_CHARS = 4;
683
691
 
684
692
 
685
693
  /**
@@ -694,7 +702,7 @@ export const TAIL_CHARS = 4;
694
702
  */
695
703
  function totalHalf(n) {
696
704
  const want = lightWidth(n) + 2 * TAIL_CHARS;
697
- const capped = Math.min(want, Math.max(2, n - 1));
705
+ const capped = Math.min(want, Math.max(2, n - 1));
698
706
  return capped / 2;
699
707
  }
700
708
 
@@ -713,44 +721,44 @@ function coreHalf(n) {
713
721
  *
714
722
  * At 30fps a frame is 33ms, which is what makes the crossing in `CROSS_MS` six frames long.
715
723
  */
716
- export const ANIM_FPS = 30;
724
+ export const ANIM_FPS = 30;
717
725
  export const FRAME_MS = 1000 / ANIM_FPS;
718
726
 
719
727
  /**
720
728
  * How long one step of the light lasts, in milliseconds.
721
729
  *
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.
730
+ * FIXED at 80ms — fast enough that a sweep reads as motion, slow enough that the eye is not chasing it.
731
+ *
732
+ * It followed the output rate for a while. That was tried and dropped: a model writing slowly got a
733
+ * sweep so slow it looked stuck, a fast one got a blur, and the same row changed pace between turns for
734
+ * no reason the reader could see. One rate is calmer and predictable, which is what an ambient
735
+ * indicator needs to be.
736
+ */
737
+ export const STEP_MS = 65;
738
+
739
+ /**
740
+ * The step for a TOOL name, slower than the Working row's.
741
+ *
742
+ * A tool name is short — `Read` is four characters — so at the Working row's rate the light crosses it in
743
+ * 560ms and the row is dark for the rest of the cycle. On screen that reads as a single flash, not as
744
+ * movement. 150ms per character gives a four-character name a 1.4s round: slow enough to follow, still
745
+ * obviously running.
746
+ */
747
+ const TOOL_STEP_MS = 150;
748
+
749
+ /**
750
+ * How long the light stays on one character, from how fast tokens are arriving.
751
+ *
752
+ * `tps` is output tokens per second for the step in flight. A fast stream should look fast — the row
753
+ * keeping a leisurely 50ms step while tokens pour in reads as the animation lagging behind the text it
754
+ * is supposed to accompany.
755
+ *
756
+ * The clamp is the important half. Under about 10 tok/s the model is thinking more than writing and the
757
+ * sweep slows down so it does not look frantic over a trickle; above about 80 tok/s it is a wall of text
758
+ * and the light moves quickly but never strobes. 28ms is just under a 33ms render frame, so a step is
759
+ * always painted at least once; 200ms is slow enough to read as deliberate without looking stuck.
728
760
  */
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
-
761
+
754
762
 
755
763
 
756
764
 
@@ -765,32 +773,39 @@ const TOOL_STEP_MS = 150;
765
773
  * ended; `lightMargin` takes the larger of the two.
766
774
  */
767
775
  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
- }
776
+ /**
777
+ * The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger —
778
+ * but CAPPED so the light reaches the text in reasonable time.
779
+ *
780
+ * Ten cells either side is what makes the timing match Claude Code on a long row. On a SHORT row it is
781
+ * most of the cycle: `Edit` is four characters, so the travel is 24 cells and the light spends the first
782
+ * 500ms still outside the text, showing nothing. A row that looks frozen for half a second and then
783
+ * blinks once reads as broken, which is exactly how the Using row was reported.
784
+ *
785
+ * The cap is `n + 4` per side at most, so a short row spends a few steps entering rather than ten, and a
786
+ * long row is unchanged.
787
+ */
788
+ function lightMargin(n) {
789
+ const half = totalHalf(Math.max(1, n));
790
+ return Math.max(1, Math.min(ENTRY_EXIT_CHARS, Math.max(half, Math.ceil(Math.max(1, n) / 4))));
791
+ }
784
792
 
785
793
  /**
786
794
  * The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
787
795
  *
788
- * `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only `count * stepMs`
789
- * of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
796
+ * `(count + 2 * lightMargin(count)) * stepMs * SWEEP_TICKS / 10`. The light is over the visible text for only
797
+ * `count * stepMs` of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on
798
+ * and off.
799
+ *
800
+ * `SWEEP_TICKS` IS THE RESOLUTION KNOB. At the old value of 10 a ten-character row crossed in 1235ms — about
801
+ * 19 frames at 65ms — and the light traversed its own five-character band in a third of that, so each glyph
802
+ * took only five distinct brightness values and the fade read as a five-step ramp. Every doubling of the tick
803
+ * count doubles the frames each glyph is under the light, which is the only way to make the fade finer without
804
+ * changing its shape or its speed across the row.
790
805
  */
791
- export function crossMs(count, stepMs = STEP_MS) {
806
+ export function crossMs(count, stepMs = STEP_MS) {
792
807
  const cells = Math.max(1, count) + 2 * lightMargin(count);
793
- return cells * stepMs;
808
+ return cells * stepMs * (SWEEP_TICKS / 10);
794
809
  }
795
810
 
796
811
  /**
@@ -803,7 +818,7 @@ export function crossMs(count, stepMs = STEP_MS) {
803
818
  * Claude Code can use zero because its entry run is ten cells: the light is already off the row for a
804
819
  * second before it comes back. Ours enters from three cells away, too short to cover the wrap.
805
820
  */
806
- export const REST_MS = 300;
821
+ export const REST_MS = 300;
807
822
 
808
823
  /**
809
824
  * The full cycle for a row: one crossing, then the rest.
@@ -811,8 +826,8 @@ export const REST_MS = 300;
811
826
  * `count` enters into it: a longer row has more characters to light, so its cycle is longer. It used to be
812
827
  * constant, because a fixed-time crossing spread itself over however many cells there happened to be.
813
828
  */
814
- export function cycleMs(count, stepMs = STEP_MS) {
815
- return crossMs(count, stepMs) + REST_MS;
829
+ export function cycleMs(count, stepMs = STEP_MS) {
830
+ return crossMs(count, stepMs) + REST_MS;
816
831
  }
817
832
 
818
833
  /**
@@ -893,7 +908,7 @@ function lightOrigin(key, anchor, now) {
893
908
  * 1.9 seconds for `Working...`. Taking milliseconds decouples the two: the caller may redraw as often
894
909
  * as it likes, and the light's speed is stated directly rather than implied by a frame count.
895
910
  */
896
- export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
911
+ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
897
912
  const n = Math.max(1, count);
898
913
  // Two widths, and the distinction is the shape of the light: `half` is the OUTER edge (core plus the
899
914
  // fade either side) and `core` is the part that stays at full brightness.
@@ -918,10 +933,17 @@ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS
918
933
  // The margin is `max(10, half)`: ten is the target, but the light must clear its own width or it would
919
934
  // still be lighting the last characters when the round ended — the failure the half-width version was
920
935
  // 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);
924
- const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
936
+ const margin = lightMargin(n);
937
+ const travel = n + 2 * margin;
938
+ // THE HEAD IS CONTINUOUS, NOT QUANTISED TO WHOLE STEPS.
939
+ //
940
+ // `Math.floor` here rounded the head to the last whole tick, so on a 65ms frame a ten-character row offered
941
+ // the light about twenty distinct positions and each glyph saw five brightness values — a five-step ramp
942
+ // where the eye can see twenty. The position is a FLOAT now: the pulse is already continuous in `dist`, so
943
+ // every frame lands the light somewhere between two cells and the fade is as fine as the frame rate allows.
944
+ // `STEP_CHARS` still scales how far one step moves, which is a shape decision, not a resolution one.
945
+ const progress = Math.max(0, Math.min(1, phaseMs / crossMs(n, stepMs)));
946
+ const head = (-margin + progress * travel) * STEP_CHARS;
925
947
  // `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
926
948
  // where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
927
949
  // is a module-level function and the TUI state lives inside startTUI's closure — the trap that once
@@ -948,13 +970,13 @@ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS
948
970
  * @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
949
971
  * @returns {string} ANSI, ready to splice into a row
950
972
  */
951
- function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
973
+ function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
952
974
  const s = String(text == null ? '' : text);
953
975
  const n = s.length;
954
976
  if (!n) return '';
955
977
  const out = [];
956
978
  for (let i = 0; i < n; i++) {
957
- const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
979
+ const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
958
980
  const r = Math.round(base[0] + (glint[0] - base[0]) * t);
959
981
  const g = Math.round(base[1] + (glint[1] - base[1]) * t);
960
982
  const b = Math.round(base[2] + (glint[2] - base[2]) * t);
@@ -988,7 +1010,12 @@ export const STEP_CHARS = 1;
988
1010
  // edge of the band to the settle point, i.e. `travel = 1 + BAND`, not `1 + 2*BAND`.
989
1011
  // Correcting that buys ~25% more frames for free, because the extra was spent sweeping
990
1012
  // through empty space before and after the row.
991
- export const SWEEP_TICKS = 10; // ticks per half-sweep (0.8s, so a 3.2s cycle)
1013
+ // FRAMES PER CHARACTER IS THE SMOOTHNESS. With `STEP_MS = 65` and a band of `n/2`, the light spends
1014
+ // `BAND * ticks / (1 + BAND)` frames on each character; at 10 ticks that is 3.3, so a glyph took only three
1015
+ // or four distinct brightness values and the fade read as a step. Doubling the tick count doubles that to
1016
+ // 6.7 — the light still crosses in the same ORDER, it just takes twice as long to do it, which is the only
1017
+ // way to buy resolution without changing the shape.
1018
+ export const SWEEP_TICKS = 20; // ticks per half-sweep (1.3s at 65ms, so a 2.6s cycle)
992
1019
  const SWEEP_TRAVEL_PAD = 1; // head runs from -BAND to 1
993
1020
 
994
1021
  /**
@@ -1563,58 +1590,58 @@ export function lineDiff(oldStr, newStr, startLine = 1) {
1563
1590
 
1564
1591
  // Word-wrap a string that may contain ANSI colour codes, measuring VISIBLE
1565
1592
  // 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
- }
1593
+ function wrapAnsiWords(str, width) {
1594
+ width = Math.max(1, width | 0);
1595
+ const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
1596
+ const out = [];
1597
+ let line = '';
1598
+ let lineW = 0;
1599
+ // THE COLOUR ACTIVE AT THE BREAK, carried onto the next row.
1600
+ //
1601
+ // A colour is opened once and closed at the end of the string, so a line wrapped mid-colour leaves the
1602
+ // continuation starting with plain text — the terminal paints it in its DEFAULT foreground, which on a
1603
+ // dark theme is brighter than the grey the rest of the block uses. That is what a wrapped, expanded
1604
+ // command looked like: the first row grey and every row below it lighter.
1605
+ //
1606
+ // Tracked from the escapes seen so far and re-emitted at the start of each new row, then cleared. `0m`
1607
+ // resets it, so a line that closes its colour before the break does not reopen one.
1608
+ let active = '';
1609
+ const scan = (text) => {
1610
+ for (const m of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
1611
+ active = m[1] === '0' || m[1] === '' ? '' : `\x1b[${m[1]}m`;
1612
+ }
1613
+ };
1614
+ const push = () => { if (line !== '') out.push(line); line = active; lineW = 0; };
1615
+ for (const tok of tokens) {
1616
+ if (tok === '') continue;
1617
+ const tw = visualCol(tok);
1618
+ if (/^\s+$/.test(tok)) {
1619
+ if (lineW > 0) { line += tok; lineW += tw; }
1620
+ continue;
1621
+ }
1622
+ if (lineW > 0 && lineW + tw > width) push();
1623
+ scan(tok);
1624
+ if (tw > width && visualCol(tok) > width) {
1625
+ // Hard-split an over-long token by visible columns.
1626
+ let chunk = '';
1627
+ let cw = 0;
1628
+ for (const ch of tok) {
1629
+ const chw = visualCol(ch);
1630
+ if (cw + chw > width) { out.push(chunk); chunk = active; cw = 0; }
1631
+ chunk += ch; cw += chw;
1632
+ }
1633
+ if (chunk) { line = chunk; lineW = cw; }
1634
+ continue;
1635
+ }
1636
+ line += tok;
1637
+ lineW += tw;
1638
+ }
1639
+ if (line !== '') out.push(line);
1640
+ if (out.length === 0) out.push('');
1641
+ // Rows that were seeded with a carried colour but ended up holding nothing are dropped: they would
1642
+ // render as an invisible escape-only line.
1643
+ return out.map((s) => s.replace(/^\x1b\[[0-9;]*m$/, '')).filter((s) => s !== '' || out.length === 1);
1644
+ }
1618
1645
 
1619
1646
  // Word-aware wrap that also returns each row's starting offset in the source
1620
1647
  // string, so the caret can be mapped to a (row, col) inside the composer.
@@ -2125,7 +2152,7 @@ function keyArgument(name, args, workspace, expanded = false) {
2125
2152
  }
2126
2153
  return '';
2127
2154
  }
2128
- export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false) {
2155
+ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false, width = 0) {
2129
2156
  const name = msg.toolName || '';
2130
2157
  const arg = keyArgument(name, msg.toolArgs, workspace, expanded);
2131
2158
  const verb = msg.pending ? 'Using' : 'Used';
@@ -2169,21 +2196,21 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2169
2196
  // `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time to
2170
2197
  // zero so the light never moved at all.
2171
2198
  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}`;
2199
+ // THE KEY IS THE ROW'S OWN IDENTITY, and nothing else.
2200
+ //
2201
+ // It used to carry `anchorSpin` as well, on the reasoning that the same message re-animated on a new
2202
+ // tick should get a new origin. The opposite happened: `anchorSpin` is `pulseStart ?? spin`, and when
2203
+ // the caller passes no `pulseStart` it IS the tick counter — it changes every 80ms, so every frame
2204
+ // built a new key, `lightOrigin` created a fresh origin for it, and `elapsedMs` stayed at 0. The light
2205
+ // sat still, except for the one frame after the row content changed and the message object was
2206
+ // replaced.
2207
+ //
2208
+ // A row animates from the moment it appears until it stops being pending, and its id is stable across
2209
+ // that whole life: one origin, and elapsed grows from there.
2210
+ const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}` : `tool:${name}`;
2184
2211
  const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
2185
2212
  // ONE shimmer for both rows — see `shimmerText`.
2186
- const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
2213
+ const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
2187
2214
 
2188
2215
  // The same restraint as the Working row's glint: the light is the base colour with a small lift in
2189
2216
  // the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
@@ -2191,12 +2218,12 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2191
2218
  const BASE = [0, 215, 255]; // cyan: the name's resting colour
2192
2219
  const GLINT = [110, 240, 255]; // the same hue, a shade brighter
2193
2220
 
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
- }
2221
+ // The same setting the Working row honours: a pending tool's name is decoration too.
2222
+ if (reducedMotion) {
2223
+ nameDisplay = col(name, C.cyan);
2224
+ } else {
2225
+ nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS);
2226
+ }
2200
2227
  } else {
2201
2228
  // Non-pending: show in plain cyan
2202
2229
  nameDisplay = col(name, C.cyan);
@@ -2457,11 +2484,13 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2457
2484
  // Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
2458
2485
  // The body already carries its own ANSI colours, so mark it pre-colored.
2459
2486
  if (msg.role === 'tool') {
2460
- const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion);
2487
+ // The bullet's width is known before the body is built, and the body needs it: a pending tool's name is
2488
+ // swept across the row it has, not across its own four characters.
2489
+ const bodyW = Math.max(1, width - visualCol('● '));
2490
+ const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion, bodyW);
2461
2491
  // Red bullet for a failed call (a Bash non-zero exit / [error: …]).
2462
2492
  const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
2463
2493
  const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
2464
- const bodyW = Math.max(1, width - visualCol('● '));
2465
2494
  // EXPANDED: break the body at the newlines the command actually has, so a multi-line script renders
2466
2495
  // as the lines it was written as. `wrapAnsiWords` alone flattens a newline into a space, turning
2467
2496
  // `set -e\nnpm ci\nnpm test` into one long reflowed line — and the reason to expand is to read the
@@ -3001,12 +3030,16 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
3001
3030
  const { headRows, tailRows, headCut } = renderMdIncremental(msg, String(msg.text || ''), bodyW, fg, codeFg);
3002
3031
  const c = msg._mdCache;
3003
3032
  let headWrapped = c && c.headWrapped;
3004
- if (!headWrapped || c.headWrappedCut !== headCut) {
3033
+ if (!headWrapped || c.headWrappedCut !== headCut || c.headWrappedVersion !== MD_RENDER_VERSION) {
3005
3034
  // ONLY the first row of the whole message carries the `❯` marker; every later
3006
3035
  // row uses the continuation pad. Using `prefix` for every head row put a `❯`
3007
3036
  // on each line of the message.
3008
3037
  headWrapped = headRows.map((ml, i) => ({ text: ml, ind: i === 0 ? prefix : contPad, raw: true }));
3009
- if (c) { c.headWrapped = headWrapped; c.headWrappedCut = headCut; }
3038
+ if (c) {
3039
+ c.headWrapped = headWrapped;
3040
+ c.headWrappedCut = headCut;
3041
+ c.headWrappedVersion = MD_RENDER_VERSION;
3042
+ }
3010
3043
  }
3011
3044
  for (const w of headWrapped) lines.push(w);
3012
3045
  const tailBase = lines.length;
@@ -3053,22 +3086,38 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
3053
3086
  // separately. That is NOT equivalent — a trailing blank line contributes a row on
3054
3087
  // its own but acts as a separator inside the whole document — so the two renders
3055
3088
  // disagreed by a row. Blocks avoid the problem entirely.)
3089
+ /**
3090
+ * Version of the markdown renderer, part of the incremental cache key.
3091
+ *
3092
+ * That cache is validated on the INPUT — text, width, colours — which cannot see that the renderer
3093
+ * itself now produces different rows for the same input. A session that cached a block before a fence
3094
+ * type learned to draw kept showing the old output, so the change looked like it had not shipped.
3095
+ * Bump this whenever what `renderMdText` produces for the same input changes.
3096
+ */
3097
+ const MD_RENDER_VERSION = 7; // 7: an image row carries the TeX that produced it
3098
+
3056
3099
  export function renderMdIncremental(msg, text, width, fg, codeFg) {
3057
3100
  const src = text.replace(/\r\n/g, '\n');
3058
3101
  let c = msg._mdCache;
3059
3102
  // Layout identity: same width/colours and the same text. Comparing the text (not
3060
3103
  // 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) {
3104
+ if (c && c.v === MD_RENDER_VERSION && c.width === width && c.fg === fg && c.codeFg === codeFg && c.text === src) {
3062
3105
  return { headRows: c.headRows, tailRows: c.tailRows, headCut: c.headCut };
3063
3106
  }
3064
- if (!c || c.width !== width || c.fg !== fg || c.codeFg !== codeFg) {
3065
- c = msg._mdCache = { width, fg, codeFg };
3107
+ if (!c || c.v !== MD_RENDER_VERSION || c.width !== width || c.fg !== fg || c.codeFg !== codeFg) {
3108
+ c = msg._mdCache = { v: MD_RENDER_VERSION, width, fg, codeFg };
3066
3109
  }
3067
3110
 
3068
3111
  const parts = splitBlocks(src);
3069
3112
  // A block is final once a LATER block exists; the last one is still growing.
3070
3113
  const headCount = Math.max(0, parts.length - 1);
3071
- const canReuse = Array.isArray(c.blocks)
3114
+ // THE CACHED BLOCKS BELONG TO A VERSION OF THE RENDERER. Without that check a request for the same text,
3115
+ // width and colours reuses rows produced by an OLDER renderer: the fast path above rejects them on `c.v`,
3116
+ // but it leaves `c` untouched (its width and colours still match), and this reuse test then finds the block
3117
+ // texts identical and hands back the old rows. That is why an image row kept arriving without the TeX it
3118
+ // was made from after the field was added.
3119
+ const canReuse = c.v === MD_RENDER_VERSION
3120
+ && Array.isArray(c.blocks)
3072
3121
  && Array.isArray(c.blockTexts)
3073
3122
  && c.blockTexts.length >= headCount
3074
3123
  && parts.length >= headCount
@@ -3081,6 +3130,7 @@ export function renderMdIncremental(msg, text, width, fg, codeFg) {
3081
3130
  const headCut = parts.length > 1 ? parts.slice(0, headCount).join('\n').length : 0;
3082
3131
 
3083
3132
  msg._mdCache = {
3133
+ v: MD_RENDER_VERSION,
3084
3134
  text: src, width, fg, codeFg,
3085
3135
  blocks, blockTexts: parts.slice(0, headCount),
3086
3136
  headRows, tailRows, headCut,
@@ -3126,16 +3176,112 @@ export function renderMdText(text, width, fg, codeFg) {
3126
3176
  while (i < para.length) {
3127
3177
  let line = para[i];
3128
3178
  const t = line.replace(/\s+$/, '');
3179
+ // DISPLAY MATH: `$$ ... $$` and `\[ ... \]`.
3180
+ //
3181
+ // A fence is what a model writes when it wants a code block, but a formula usually arrives as `$$`.
3182
+ // It is laid out here as the formula it describes — stacked fractions, limits above a sum — and its
3183
+ // source is what remains when the subset cannot express it, exactly as a math fence behaves.
3184
+ // The opener has to start the line and the closer has to end one, so `cost is $$x$$ total` stays prose
3185
+ // and is handled as inline math by the line renderer below.
3186
+ const mathOpen = /^\s*(\$\$|\\\[)/.exec(t);
3187
+ if (mathOpen) {
3188
+ const open = mathOpen[1];
3189
+ const close = open === '$$' ? '$$' : '\\]';
3190
+ const after = t.slice(mathOpen.index + mathOpen[0].length);
3191
+ let body = null;
3192
+ let used = 0;
3193
+ let closed = true;
3194
+ // One-line form: `$$x = 1$$` with nothing but whitespace after the closer.
3195
+ const oneLine = after.indexOf(close);
3196
+ if (oneLine >= 0 && after.slice(oneLine + close.length).trim() === '') {
3197
+ body = after.slice(0, oneLine);
3198
+ used = 1;
3199
+ } else if (after.trim() === '') {
3200
+ // Multi-line form: everything up to the line that ends with the closer.
3201
+ const collected = [];
3202
+ let k = i + 1;
3203
+ let found = false;
3204
+ while (k < para.length) {
3205
+ const cl = para[k].replace(/\s+$/, '');
3206
+ if (cl.trimEnd().endsWith(close)) {
3207
+ collected.push(cl.slice(0, cl.lastIndexOf(close)));
3208
+ k++;
3209
+ found = true;
3210
+ break;
3211
+ }
3212
+ collected.push(para[k]);
3213
+ k++;
3214
+ }
3215
+ body = collected.join('\n');
3216
+ used = k - i;
3217
+ // Still streaming: no closer yet, so the body is prose plus half a formula.
3218
+ closed = found;
3219
+ }
3220
+ if (body !== null && closed) {
3221
+ i += used;
3222
+ // Same three fallbacks as a math fence: bitmap, Unicode layout, then the source.
3223
+ const rows = mathBlockRows(body, width, true);
3224
+ if (rows) {
3225
+ out.push(...rows);
3226
+ } else {
3227
+ // Unsupported: show the TeX so the reader still gets the formula's meaning.
3228
+ for (const l of body.split('\n')) out.push(col(' ' + l.slice(0, Math.max(1, width - 2)), C.white));
3229
+ }
3230
+ continue;
3231
+ }
3232
+ }
3129
3233
  // Fenced code block. Rendered with a rule ABOVE (labelled with the language)
3130
3234
  // and a matching full-width rule BELOW, so the block is visibly delimited.
3131
- const fm = /^\s*(```+|~~~+)\s*(\S*)/.exec(t);
3235
+ const fm = /^\s*(`{3,}|~{3,})\s*(\S*)/.exec(t);
3132
3236
  if (fm) {
3133
3237
  const lang = fm[2] || '';
3134
3238
  i++;
3135
3239
  const buf = [];
3136
- while (i < para.length && !/^\s*(```+|~~~+)/.test(para[i])) { buf.push(para[i]); i++; }
3137
- i++; // closing fence
3240
+ const fenceChar = fm[1][0];
3241
+ const fenceLen = fm[1].length;
3242
+ const isCloser = (s) => {
3243
+ const m = /^\s*(`{3,}|~{3,})\s*$/.exec(s);
3244
+ return !!m && m[1][0] === fenceChar && m[1].length >= fenceLen;
3245
+ };
3246
+ while (i < para.length && !isCloser(para[i])) { buf.push(para[i]); i++; }
3247
+ // Whether the closing fence was actually reached: an open fence means the text is still streaming.
3248
+ const fenceClosed = i < para.length;
3249
+ i++; // closing fence (no-op when the fence is still open)
3138
3250
  const rule = (s) => col(s, C.gray);
3251
+ // MATH FENCES ARE TYPESET. A `latex`/`tex`/`math` fence is TeX, not code: it is drawn as the formula
3252
+ // it describes, and when the terminal speaks an image protocol it is drawn as a TYPESET BITMAP rather
3253
+ // than as flattened Unicode — the same pipeline dsh-tui uses, with STIX Two Math.
3254
+ //
3255
+ // Three fallbacks, in order, and each one keeps the formula: the bitmap; the Unicode layout when the
3256
+ // picture cannot be placed (no protocol, no cell size, too wide, too tall); the SOURCE when the subset
3257
+ // cannot express the TeX at all.
3258
+ if (/^(latex|tex|math)$/i.test(lang)) {
3259
+ const tex = buf.join('\n').trim();
3260
+ const rendered = mathBlockRows(tex, width, fenceClosed);
3261
+ if (rendered) {
3262
+ out.push(...rendered);
3263
+ continue;
3264
+ }
3265
+ // Falls through to the plain code block: the source is all we have.
3266
+ }
3267
+ if (/^mermaid$/i.test(lang)) {
3268
+ const src = buf.join('\n');
3269
+ let art = null;
3270
+ try { art = renderMermaid(src); } catch { art = null; }
3271
+ // Wider than the panel: the frame is the honest answer, since truncating art
3272
+ // would cut a box in half rather than say the diagram does not fit.
3273
+ if (art && art.width <= width) {
3274
+ // `styled` keeps the role of every span, so the frame stays dim and the
3275
+ // connectors cyan instead of the whole diagram arriving as plain white text.
3276
+ const coloured = mermaidAnsi(art);
3277
+ for (const l of coloured) out.push(l);
3278
+ continue;
3279
+ }
3280
+ try {
3281
+ for (const l of mermaidSourceBox(src, width).plain) out.push(col(l, C.white));
3282
+ continue;
3283
+ } catch { /* fall through to the plain code block */ }
3284
+ }
3139
3285
  // Opening rule: `─ <lang> ` then `─` padding out to the FULL width.
3140
3286
  // (The old version subtracted 4 and was 2 columns short of the panel.)
3141
3287
  if (lang) {
@@ -3147,13 +3293,12 @@ export function renderMdText(text, width, fg, codeFg) {
3147
3293
  // Fenced code block CONTENT is plain body text — render it in the
3148
3294
  // default terminal colour (white), not the theme's cyan. The cyan is
3149
3295
  // 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).
3296
+ // cyan reads as "everything is the theme colour".
3152
3297
  for (const cl of buf) out.push(col(cl.slice(0, width), C.white));
3153
3298
  // Closing rule, matching the opening one.
3154
3299
  out.push(rule('─'.repeat(Math.max(1, width))));
3155
3300
  continue;
3156
- }
3301
+ }
3157
3302
  // Table: a header line followed by a separator line
3158
3303
  if (t.includes('|') && i + 1 < para.length && isTableSep(para[i + 1])) {
3159
3304
  const header = splitCells(t);
@@ -3270,8 +3415,8 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
3270
3415
  return out;
3271
3416
  }
3272
3417
  }
3273
- // Inline styles: **bold**, *italic*, `code`, [text](url)
3274
- if (/[*`\[]/.test(t)) {
3418
+ // Inline styles: **bold**, *italic*, `code`, [text](url), $math$
3419
+ if (/[*`\[$]|\\\(/.test(t)) {
3275
3420
  for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg, fg), fg));
3276
3421
  return out;
3277
3422
  }
@@ -3280,6 +3425,151 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
3280
3425
  return out;
3281
3426
  }
3282
3427
 
3428
+ /**
3429
+ * Whether the terminal can receive a formula as a picture.
3430
+ *
3431
+ * Both halves are required: a protocol the terminal reported (Sixel today) and a cell size it reported
3432
+ * (XTWINOPS 16). Without the cell size the picture would be measured in guessed cells, which is what put a
3433
+ * formula on the wrong rows before — so a missing cell size falls back to text rather than guess.
3434
+ */
3435
+ function mathImageCapable() {
3436
+ const caps = terminalCaps();
3437
+ if (!caps) return null;
3438
+ // KITTY FIRST. A terminal that speaks both (Kitty can be built with Sixel support) handles Kitty far
3439
+ // better: the pixels are uploaded once and every later frame is a placement, while a Sixel has to be
3440
+ // retransmitted and moves the cursor when it is drawn.
3441
+ const protocol = caps.kittyGraphics
3442
+ ? 'kitty'
3443
+ : (Array.isArray(caps.extensions) && caps.extensions.includes('sixel') ? 'sixel' : null);
3444
+ if (!protocol) return null;
3445
+ const cell = caps.cell && caps.cell.w > 0 && caps.cell.h > 0 ? caps.cell : null;
3446
+ return cell ? { protocol, cell } : null;
3447
+ }
3448
+
3449
+ /** Ink colour for a typeset formula: the body text colour, so a picture sits in the text rather than beside it. */
3450
+ const MATH_INK = { r: 220, g: 220, b: 220 };
3451
+
3452
+ /**
3453
+ * The terminal's cell height in pixels, for cropping a picture that straddles the top of the window.
3454
+ *
3455
+ * A crop has to convert CELL ROWS to six-pixel BANDS; without the cell height the two units get confused and
3456
+ * the image is drawn from the wrong point in its own payload. 20 is the conventional monospace cell height and
3457
+ * matches the fallback `src/tex/` uses when the terminal never answers XTWINOPS 16.
3458
+ *
3459
+ * @returns {number}
3460
+ */
3461
+ function mathCellHeight() {
3462
+ const caps = terminalCaps();
3463
+ const cell = caps && caps.cell;
3464
+ return cell && cell.h > 0 ? cell.h : 20;
3465
+ }
3466
+
3467
+ /**
3468
+ * Rows for one display formula: a typeset bitmap when the terminal can show one, the Unicode layout when it
3469
+ * cannot, and nothing when the TeX is outside the subset (the caller then shows the source).
3470
+ *
3471
+ * @param {string} tex TeX without delimiters.
3472
+ * @param {number} width Columns available.
3473
+ * @param {boolean} closed The closing delimiter arrived. A formula still streaming keeps to the text form: a
3474
+ * picture of half a formula is worse than the TeX of half a formula.
3475
+ * @returns {string[]|null}
3476
+ */
3477
+ function mathBlockRows(tex, width, closed) {
3478
+ const src = stripMathDelimiters(tex);
3479
+ if (!src) return null;
3480
+ // A picture sits where the text form does: one column of indent, and no wider than the panel.
3481
+ const room = Math.max(8, width - 2);
3482
+ if (closed) {
3483
+ const placed = placeTex(src, room);
3484
+ if (placed) return placed;
3485
+ }
3486
+ const rendered = renderLatex(src, { display: true });
3487
+ if (rendered === undefined) return null;
3488
+ return rendered
3489
+ .split('\n')
3490
+ .map((l) => col(l.slice(0, width), C.white));
3491
+ }
3492
+
3493
+ /**
3494
+ * Remove the delimiters a formula may still be wrapped in.
3495
+ *
3496
+ * A `$$…$$` block and a ```latex fence both reach the typesetter, and the fence's content may carry the
3497
+ * delimiters too — a model writes the fence around `$$…$$` often enough that it is the common case, not the
3498
+ * odd one. Typesetting the delimiters along with the formula draws them as glyphs inside the picture, which
3499
+ * is what a literal `$$` in the middle of an integral looks like.
3500
+ *
3501
+ * Only a delimiter that spans the WHOLE body is removed: a `$$` in the middle is TeX the author meant, and
3502
+ * stripping it would change the formula. The loop runs because a doubled wrapper (`\n$$\n…\n$$\n` inside a
3503
+ * fence) leaves one pair behind after the first pass.
3504
+ *
3505
+ * @param {string} tex
3506
+ * @returns {string}
3507
+ */
3508
+ function stripMathDelimiters(tex) {
3509
+ let s = String(tex || '').trim();
3510
+ for (;;) {
3511
+ const before = s;
3512
+ 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
+ } else if (s.startsWith('\\(') && s.endsWith('\\)') && s.length > 4) {
3517
+ s = s.slice(2, -2).trim();
3518
+ }
3519
+ if (s === before) return s;
3520
+ }
3521
+ }
3522
+
3523
+
3524
+ /**
3525
+ * Typeset a formula into a frame row, or null when it cannot be placed as a picture here.
3526
+ *
3527
+ * The picture is one row of TEXT plus a payload: the row is the sentinel `image-row.js` understands, the
3528
+ * rows below it are blank, and the frame writer turns them into a cursor move and a Sixel sequence. Every
3529
+ * reason to decline is a reason the caller already has a text fallback for, so this never throws.
3530
+ *
3531
+ * @param {string} tex
3532
+ * @param {number} room Columns the picture may occupy.
3533
+ */
3534
+ function placeTex(tex, room) {
3535
+ const capable = mathImageCapable();
3536
+ if (!capable) return null;
3537
+ try {
3538
+ // Measured first, because laying out is what rejects TeX outside the subset: a raster would be paid for
3539
+ // and then thrown away.
3540
+ const measured = measureTex(tex, { cell: capable.cell });
3541
+ if (!measured || measured.columns > room || measured.rows > TEX_MAX_ROWS) return null;
3542
+ const image = encodeTexImage(tex, { protocol: capable.protocol, cell: capable.cell, colour: MATH_INK });
3543
+ if (!image) return null;
3544
+ // THE SOURCE TRAVELS WITH THE ROW. A picture that straddles the top of the window has to be redrawn from
3545
+ // its first VISIBLE row, and only the TeX can produce that raster: cropping the encoded payload can only
3546
+ // land on a six-pixel band boundary, which leaves up to a quarter of a cell of the formula above the
3547
+ // window and makes it slide as the view scrolls. `paintCellFrame` reads this back and re-renders with
3548
+ // `skipTop` when a picture needs cropping.
3549
+ const rows = [encodeImageRowHelper(image.sequence, image.rows, image.columns, tex)];
3550
+ for (let r = 1; r < image.rows; r++) rows.push('');
3551
+ return rows;
3552
+ } catch {
3553
+ // A bitmap that cannot be drawn is a reason to show text, never a reason to lose the frame.
3554
+ return null;
3555
+ }
3556
+ }
3557
+
3558
+ /**
3559
+ * Whether a `$...$` body is a formula rather than prose.
3560
+ *
3561
+ * Prices, shell variables and identifiers all use `$`: `costs $5 and $10`, `$HOME`, `${VAR}`. A body that
3562
+ * starts or ends with a space, is a bare number or an all-caps word is prose, and so is one with no TeX
3563
+ * command and no operator in it — `vitamin $C$` stays text while `$x^2$` becomes a formula.
3564
+ */
3565
+ function looksLikeInlineMath(body) {
3566
+ if (/^\s/.test(body) || /\s$/.test(body)) return false;
3567
+ if (body.startsWith('{') || body.includes('`')) return false;
3568
+ if (/^\d+([.,]\d+)?$/.test(body)) return false;
3569
+ if (/^[A-Z][A-Z0-9_]*$/.test(body)) return false;
3570
+ return /\\[A-Za-z]+|[=+\-*/<>^_]/.test(body) || (/^[A-Za-z]$/.test(body));
3571
+ }
3572
+
3283
3573
  // Apply inline markdown to a single line: `code`, **bold**, *italic*, ~~strike~~,
3284
3574
  // [text](url). Order matters so inner tokens are handled safely.
3285
3575
  // `base` is the surrounding foreground colour; every inline style re-asserts it
@@ -3291,6 +3581,17 @@ function inlineMarkdown(s, codeFg, base) {
3291
3581
  let r = s;
3292
3582
  // Inline code first so it is not mangled by * [] ~.
3293
3583
  r = r.replace(/`([^`]+)`/g, (_, c) => col(c, codeFg) + fb);
3584
+ // INLINE MATH. After the code spans, so a formula inside backticks stays code, and before emphasis, so
3585
+ // `*` and `_` inside TeX cannot turn into italics. `\(...\)` is the other inline form models use.
3586
+ r = r.replace(/\$([^$\n]+?)\$|\\\(([^\n]+?)\\\)/g, (m, dollar, paren) => {
3587
+ const tex = dollar !== undefined ? dollar : paren;
3588
+ // A price or a shell variable is not math: `$5 and $10`, `$HOME`, `${VAR}`.
3589
+ if (dollar !== undefined && !looksLikeInlineMath(tex)) return m;
3590
+ const out = renderLatex(tex, { display: false });
3591
+ // Unsupported or multi-line: the source stands, because inline math sits inside wrapped prose.
3592
+ if (out === undefined || out.includes('\n')) return m;
3593
+ return out;
3594
+ });
3294
3595
  // Bold
3295
3596
  r = r.replace(/\*\*([^*]+)\*\*/g, (_, c) => C.bold + c + C.reset + fb);
3296
3597
  // Strikethrough
@@ -3656,12 +3957,12 @@ export function renderChatLines(state, w, viewport) {
3656
3957
  const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
3657
3958
  const rowWidth = border ? boxText : innerW;
3658
3959
  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;
3960
+ // Which rows animate? A pending tool call and a running compaction block do, and their appearance
3961
+ // depends on the current step of the light. Everything else gets a constant key, so a static row is
3962
+ // laid out once and reused across frames.
3963
+ // A pending tool advances from the wall clock at TOOL_STEP_MS, so its cache key is THAT step index.
3964
+ const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
3965
+ const spinKeyNow = animating ? Math.floor(Date.now() / TOOL_STEP_MS) : 0;
3665
3966
  const valid = cached
3666
3967
  && cached.rowW === rowWidth && cached.expanded === expanded
3667
3968
  && cached.textRef === m.text && cached.streamRef === m.streamContent
@@ -3715,8 +4016,11 @@ export function renderChatLines(state, w, viewport) {
3715
4016
  // happen (replacing a word). Those are caught by the `_cache` nulling at the
3716
4017
  // edit sites, which is what the shared-store path below relies on too.
3717
4018
  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) {
4019
+ // Only a NON-ANIMATING message can be answered from its row count. See the note below.
4020
+ // A message that may hold a bitmap cannot be answered from a row count: the count says how many rows it
4021
+ // occupies, not what is drawn in them.
4022
+ const hasFence = /```+\s*(latex|tex|math)\b/i.test(typeof m.text === 'string' ? m.text : '');
4023
+ if (!hasFence && !valid && !cached && !animating && m._count && m._count.key === countKey && m._count.textLen === countLen) {
3720
4024
  msgLen[mi] = m._count.len + rules;
3721
4025
  cursor += msgLen[mi];
3722
4026
  prevBordered = border;
@@ -3741,12 +4045,13 @@ export function renderChatLines(state, w, viewport) {
3741
4045
  && shared.liveRef === m.liveOutput && shared.diffRef === m.diff
3742
4046
  && shared.countsRef === m._diffCounts
3743
4047
  && shared.keyArgs === (m.toolArgs ? fpArgs(m.toolArgs) : '0')
4048
+ && shared.v === MD_RENDER_VERSION
3744
4049
  && shared.pluginExpanded === (m.pluginLog ? !!m.expanded : null)) {
3745
4050
  m._cache = shared;
3746
4051
  msgLen[mi] = shared.rows.length + rules;
3747
4052
  } else {
3748
4053
  const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3749
- state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
4054
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
3750
4055
  reducedMotionOn(state));
3751
4056
  const wrapped = rows.map(rowToLine);
3752
4057
  const isPending2 = m.pending ? 1 : 0;
@@ -3762,6 +4067,7 @@ export function renderChatLines(state, w, viewport) {
3762
4067
  pluginExpanded: m.pluginLog ? !!m.expanded : null,
3763
4068
  raw: !!state.rawMode,
3764
4069
  rows: wrapped, spinRows: null,
4070
+ v: MD_RENDER_VERSION, // invalidates rows built by an older renderer
3765
4071
  };
3766
4072
  for (let i = 0; i < wrapped.length; i++)
3767
4073
  if (wrapped[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
@@ -3887,27 +4193,30 @@ export function renderChatLines(state, w, viewport) {
3887
4193
  && rowStart[mi - 1] + msgLen[mi - 1] === cursor;
3888
4194
  }
3889
4195
  const len = msgLen[mi];
3890
- const visible = visibleTo < 0 || (cursor + len > visibleFrom && cursor < visibleTo);
4196
+ // A message that may contain a bitmap is always built, visible or not: a row COUNT cannot represent a
4197
+ // picture, and a stale payload is worse than no payload.
4198
+ const mayHoldImage = /```+\s*(latex|tex|math)\b/i.test(typeof msg.text === 'string' ? msg.text : '');
4199
+ const visible = mayHoldImage
4200
+ || visibleTo < 0
4201
+ || (cursor + len > visibleFrom && cursor < visibleTo);
3891
4202
  if (visible) {
3892
4203
  // Pass 1 may have skipped building the rows for a message far from the
3893
4204
  // viewport (it only needed the row COUNT, so it used `_count`). Now that the
3894
4205
  // message IS visible, build them here — otherwise an evicted message rendered
3895
4206
  // as blank rows, which is what made scrolled-back history look like it had
3896
4207
  // "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
- }
4208
+ // Rows are re-derived every frame. Everything expensive inside `messageLines` is cached under a
4209
+ // COMPLETE key (the markdown block cache) or in the shared row LRU; what used to be cached HERE was the
4210
+ // final string, validated on a row count that cannot see a clipped payload.
4211
+ const bw = bordered ? boxText : innerW;
4212
+ const src = messageLines(msg, bw, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
4213
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
4214
+ const ccc = { rows: src.map(rowToLine), spinRows: null };
4215
+ for (let i = 0; i < ccc.rows.length; i++) {
4216
+ if (ccc.rows[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
4217
+ if (!ccc.spinRows) ccc.spinRows = new Set();
4218
+ ccc.spinRows.add(i);
3909
4219
  }
3910
- msg._cache = ccc;
3911
4220
  }
3912
4221
  const spinRows = ccc.spinRows;
3913
4222
  const liveAt = (r, i) => (spinRows && spinRows.has(i) ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
@@ -3916,10 +4225,20 @@ export function renderChatLines(state, w, viewport) {
3916
4225
  const pad = ' '.repeat(boxPad);
3917
4226
  const wall = col('│', boxColor);
3918
4227
  for (let i = 0; i < ccc.rows.length; i++) {
3919
- out.push(PAD + wall + pad + fitAnsi(liveAt(ccc.rows[i], i), boxText) + pad + wall);
4228
+ const rowText = liveAt(ccc.rows[i], i);
4229
+ if (isImageRow(rowText)) {
4230
+ // Emitted as-is: the frame writer turns it into a cursor move plus the bitmap.
4231
+ out.push(rowText);
4232
+ continue;
4233
+ }
4234
+ out.push(PAD + wall + pad + fitAnsi(rowText, boxText) + pad + wall);
3920
4235
  }
3921
4236
  } else {
3922
- for (let i = 0; i < ccc.rows.length; i++) out.push(PAD + liveAt(ccc.rows[i], i));
4237
+ for (let i = 0; i < ccc.rows.length; i++) {
4238
+ const rowText = liveAt(ccc.rows[i], i);
4239
+ // An image row must arrive at the cursor with nothing in front of its introducer.
4240
+ out.push(isImageRow(rowText) ? rowText : PAD + rowText);
4241
+ }
3923
4242
  }
3924
4243
  if (bordered) out.push(rule(false, boxColor));
3925
4244
  } else {
@@ -3969,10 +4288,10 @@ export function metricsSignature(state, n, w, expanded, innerW) {
3969
4288
  mix(m.pluginLog && m.expanded ? 1 : 0);
3970
4289
  mix(m._cache ? 1 : 0);
3971
4290
  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));
4291
+ // A pending row repaints as the light moves, with none of the fields above changing. Without this
4292
+ // the signature matched every frame and the transcript was never re-walked, so the animation froze
4293
+ // until the message itself was touched.
4294
+ if (m.pending) mix(Math.floor(Date.now() / STEP_MS));
3976
4295
  }
3977
4296
  return h;
3978
4297
  }
@@ -4158,6 +4477,9 @@ function sparkChart(values, width, height) {
4158
4477
  // shows at least 1%, clamped to [0, 100]. A non-positive or non-finite `max`
4159
4478
  // reports 0.
4160
4479
  function usagePercent(used, max) {
4480
+ // `used` can be undefined on a session resumed before any usage was recorded. Without this guard the
4481
+ // division produced NaN and the row printed `NaN%` where it should have printed `0%`.
4482
+ if (!Number.isFinite(used)) return 0;
4161
4483
  if (!Number.isFinite(max) || max <= 0) return 0;
4162
4484
  return Math.min(100, Math.max(0, Math.ceil((used / max) * 100)));
4163
4485
  }
@@ -4405,58 +4727,118 @@ export function questionBoxClampedHeight(state, insideW, h, chromeH) {
4405
4727
  // the thumb's travel is monotonic, uses every row, and lands exactly on both
4406
4728
  // ends; `thumbLenF` is kept unrounded purely so the grab offset inside the thumb
4407
4729
  // is not quantised to a whole row when the thumb is 10+ rows tall.
4730
+ /**
4731
+ * Sub-cell resolution of the scrollbar, in eighths of a row.
4732
+ *
4733
+ * The block glyphs `▁▂▃▄▅▆▇█` ARE a vertical eighths scale, so the bar can sit at any of eight heights
4734
+ * inside a cell. Using only two (`▀`/`▄`) gave the thumb a handful of reachable positions on a long
4735
+ * transcript — measured, 35 positions for 482 rows of scrolled content, so it jumped ~14 rows at a time
4736
+ * and the motion read as a series of hops.
4737
+ */
4738
+ const SUB_CELLS = 8;
4739
+
4740
+ /**
4741
+ * The scrollbar thumb is this many cells tall, always.
4742
+ *
4743
+ * A constant height, because the bar reports POSITION rather than density. Proportional sizing is
4744
+ * tempting and wrong here: it collapsed to a fraction of a cell on a long transcript, then grew a cell
4745
+ * at a time as content arrived, so the control changed size while being used.
4746
+ */
4747
+ const THUMB_CELLS = 2;
4748
+
4408
4749
  export function scrollbarGeometry({ total, bodyH, scroll }) {
4409
4750
  const maxScroll = Math.max(0, total - bodyH);
4410
4751
  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);
4752
+
4753
+ if (total <= bodyH || bodyH <= 0) {
4754
+ // Everything fits: no rail, and nothing is drawn for a control that would have nothing to say.
4755
+ return {
4756
+ thumbStart: 0,
4757
+ thumbLen: bodyH,
4758
+ thumbLenF: bodyH,
4759
+ maxThumbStartF: 0,
4760
+ virtualThumbStart: 0,
4761
+ virtualThumbSize: bodyH * SUB_CELLS,
4762
+ virtualTrack: bodyH * SUB_CELLS,
4763
+ bodyH,
4764
+ total,
4765
+ maxScroll,
4766
+ };
4767
+ }
4768
+
4769
+ // PROPORTIONAL HEIGHT, WHOLE ROWS, at least one — Codewhale's rule:
4770
+ // thumb = ceil(rail * visible / totalRows), clamped to [1, rail]
4771
+ // travel = rail - thumb
4772
+ // top = floor(travel * offset / maxOffset)
4773
+ //
4774
+ // It works in whole rows and so does this. Sub-cell positions were tried at length here: every scheme
4775
+ // that put an edge inside a cell had to state the fraction with `▀` (four eighths) or leave it blank,
4776
+ // so the same thumb drew between two and five eighths tall depending on where it sat, and the bar
4777
+ // visibly changed length as it scrolled. One row of thumb is the smallest honest unit.
4778
+ const visible = Math.max(1, Math.min(total, bodyH));
4779
+ const thumbLen = Math.max(1, Math.min(bodyH, Math.ceil((bodyH * visible) / total)));
4780
+ const travel = Math.max(0, bodyH - thumbLen);
4781
+ // `scroll` counts UP from the tail, the rail runs top-down, so the offset is inverted here.
4782
+ const thumbStart = maxScroll === 0 ? 0 : Math.min(travel, travel - Math.floor((travel * pos) / maxScroll));
4438
4783
 
4439
4784
  return {
4440
- thumbStart, thumbLen: thumb, thumbLenF, maxThumbStartF,
4441
- virtualThumbStart, virtualThumbSize, virtualTrack,
4442
- bodyH, total, maxScroll,
4785
+ thumbStart,
4786
+ thumbLen,
4787
+ thumbLenF: thumbLen,
4788
+ maxThumbStartF: travel,
4789
+ virtualThumbStart: thumbStart * SUB_CELLS,
4790
+ virtualThumbSize: thumbLen * SUB_CELLS,
4791
+ virtualTrack: bodyH * SUB_CELLS,
4792
+ bodyH,
4793
+ total,
4794
+ maxScroll,
4443
4795
  };
4444
4796
  }
4445
4797
 
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).
4798
+ /**
4799
+ * The glyph for one body row, from the thumb's coverage of that row.
4800
+ *
4801
+ * `▁▂▃▄▅▆▇█` are eighths of a cell filled from the BOTTOM, which is the natural scale for a vertical bar
4802
+ * whose trailing edge is what the eye follows. A full row is `█`; a row the thumb ends inside shows the
4803
+ * fraction covered; a row the thumb starts inside shows `▀` when at least half of it is covered, and is
4804
+ * left blank otherwise so the bar never claims more than the thumb holds.
4805
+ *
4806
+ * That asymmetry is deliberate: the leading edge is only half-cell accurate, because no glyph fills an
4807
+ * arbitrary fraction from the TOP. The trailing edge carries the fine motion — eight steps per row
4808
+ * instead of two, so a short thumb travels visibly instead of hopping a whole row at a time.
4809
+ */
4810
+ /** The vertical eighth-blocks, tallest last. */
4811
+ /**
4812
+ * One row of the scrollbar.
4813
+ *
4814
+ * The thumb is drawn from its BOTTOM edge upward: `▁▂▃▄▅▆▇█` fill a cell from the floor, so a row that
4815
+ * holds the thumb's bottom edge can state exactly how much of it is covered. That keeps the drawn height
4816
+ * equal to the thumb's real height to within one subunit.
4817
+ *
4818
+ * A row that holds only the thumb's TOP edge is the hard case: nothing fills from the ceiling by a
4819
+ * fraction, so the choice is `▀` (four subunits) or nothing. It is used only when the coverage really is
4820
+ * at least four — using it for less is what made the bar draw seven subunits for a five-subunit thumb,
4821
+ * i.e. visibly grow and shrink as it moved.
4822
+ */
4823
+ /**
4824
+ * One row of the scrollbar, assembled so the drawn height matches the thumb.
4825
+ *
4826
+ * A cell can hold only one character, and the available pieces are:
4827
+ * `▁▂▃▄▅▆▇█` — filled from the FLOOR by 1..8 eighths
4828
+ * `▀` — the TOP half (four eighths)
4829
+ *
4830
+ * So the two edges are handled by whichever piece fits them: a cell holding the thumb's BOTTOM edge
4831
+ * reaches the floor and the eighths say exactly how much; a cell holding its TOP edge gets `▀` when at
4832
+ * least half of it is covered. Composing the two across adjacent rows keeps the total within one eighth
4833
+ * of the thumb's real height — measured, the painted height varies by at most 1 subunit instead of the
4834
+ * 3 it did when a whole cell was guessed at once.
4835
+ */
4450
4836
  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 ' ';
4837
+ if (!g || row < 0 || row >= g.bodyH) return ' ';
4838
+ // Codewhale draws the thumb as a solid block and the rest of the rail as a vertical line, so the
4839
+ // control reads as a track with a handle on it rather than as a stick floating on nothing.
4840
+ if (g.total <= g.bodyH) return ' '; // nothing to scroll: no rail
4841
+ return row >= g.thumbStart && row < g.thumbStart + g.thumbLen ? '█' : '│';
4460
4842
  }
4461
4843
 
4462
4844
  // Map a position on the track to a transcript scroll offset. `rowF` is the mouse
@@ -5215,6 +5597,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5215
5597
  const bodyH = Math.max(1, h - bottomH);
5216
5598
 
5217
5599
  const lines = [];
5600
+ const body = []; // the whole transcript; `lines` receives the window onto it
5218
5601
  let dialogCaret = null;
5219
5602
  // The AskUserQuestion box's free-text caret, recorded as an absolute frame row
5220
5603
  // (the box sits mid-frame, not at the top), corrected with topPad/topTrim below.
@@ -5598,13 +5981,24 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5598
5981
  if (state.selection && state.selection.anchor && state.selection.head) {
5599
5982
  applySelection(chat, state.selection);
5600
5983
  }
5601
- for (let i = 0; i < bodyH; i++) {
5602
- const idx = start + i;
5984
+ // EVERY TRANSCRIPT ROW, not just the visible ones: the frame describes the whole content, so a change
5985
+ // in height above the window is visible to the diff. `start` is the window offset, used by the painter.
5986
+ for (let i = 0; i < Math.max(total, bodyH); i++) {
5987
+ const idx = i;
5988
+ // Where this row lands on screen. The window starts at `start` (`-padding` while the transcript is
5989
+ // shorter than the body), so a row outside [0, bodyH) is composed but never painted.
5990
+ const screenRow = idx - Math.max(0, start);
5603
5991
  let row = '';
5604
5992
  if (idx >= 0 && idx < total) {
5605
5993
  row = chat[idx] || '';
5606
5994
  }
5607
- if (sb) {
5995
+ // THE BAR IS ADDRESSED BY SCREEN ROW, not by transcript row.
5996
+ //
5997
+ // It used to pass the transcript index here while the loop still walked the whole transcript, so the
5998
+ // rail was drawn from the geometry of the FIRST `bodyH` rows of the conversation and every row actually
5999
+ // on screen fell outside that range — the bar came out blank as soon as the transcript was scrolled off
6000
+ // the top, i.e. in the normal case, and only appeared when the window happened to start at row 0.
6001
+ if (sb && screenRow >= 0 && screenRow < bodyH) {
5608
6002
  // The scrollbar cell is drawn EXACTLY the way OpenTUI's SliderRenderable draws
5609
6003
  // it (packages/core/src/renderables/Slider.ts, `renderVertical`):
5610
6004
  //
@@ -5622,8 +6016,8 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5622
6016
  // (`ESC[2m`), which fades the FOREGROUND only, so an open picker left the
5623
6017
  // thumb's background half at full brightness while the rest of the screen
5624
6018
  // receded.
5625
- const glyphChar = scrollbarGlyph(sb, i);
5626
- const onThumb = glyphChar !== ' ';
6019
+ const glyphChar = scrollbarGlyph(sb, screenRow);
6020
+ const onThumb = glyphChar === '█'; // the rail is `│`, the handle is `█`
5627
6021
  let fg;
5628
6022
  if (onThumb) {
5629
6023
  if (state.sbDrag) fg = C.scrollThumbActiveFg;
@@ -5633,9 +6027,22 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5633
6027
  fg = C.scrollTrackFg;
5634
6028
  }
5635
6029
  const bg = C.scrollTrackBg;
5636
- row = fitAnsi(row, w - 1) + fg + bg + glyphChar + C.reset;
6030
+ // A bitmap row is a terminal payload, so it cannot be clipped — but the SCROLLBAR STILL BELONGS ON IT.
6031
+ //
6032
+ // Skipping the append for an image row left its last column empty, and a formula is only as wide as
6033
+ // its own picture (eight cells of an eighty-column row): the rail vanished for every row a formula
6034
+ // covered. The payload is padded to the column before the rail instead of being cut, so the sequence
6035
+ // survives intact and the rail is drawn where it always is.
6036
+ if (isImageRow(row)) {
6037
+ const rail = fg + bg + glyphChar + C.reset;
6038
+ row = row + rail;
6039
+ } else {
6040
+ row = fitAnsi(row, w - 1) + fg + bg + glyphChar + C.reset;
6041
+ }
5637
6042
  }
5638
- lines.push(row);
6043
+ body.push(row);
6044
+ // Only the window reaches the frame; the rest lives in `body` for the diff to compare.
6045
+ if (screenRow >= 0 && screenRow < bodyH) lines.push(row);
5639
6046
  }
5640
6047
  state._sb = sb;
5641
6048
  state._bodyTop = start;
@@ -5647,6 +6054,18 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5647
6054
  // mapping is recorded here rather than assumed — hitboxes already do the same
5648
6055
  // `+ topPad - topTrim` correction (see the end of this function) and the mouse
5649
6056
  // mapping did not, which is what made a drag copy the wrong lines.
6057
+
6058
+ // Pad the front when the transcript is shorter than the window: the blank rows belong ABOVE the first
6059
+ // message, so the content sits directly over the composer. Without this the composer followed the last
6060
+ // message and the padding pass pushed the whole body to the top of the screen.
6061
+ // A SHORT TRANSCRIPT SITS AT THE BOTTOM, ABOVE THE COMPOSER.
6062
+ //
6063
+ // When the content is shorter than the window, `start` is negative and the loop emits only the rows
6064
+ // that exist — so `lines` held five rows where the window has forty, the composer was appended
6065
+ // directly after them, and the padding pass then pushed the whole body to the TOP of the screen.
6066
+ // The blank rows belong at the FRONT: above the first message.
6067
+ const missing = bodyH - lines.length;
6068
+ if (missing > 0) for (let pad = 0; pad < missing; pad++) lines.unshift('');
5650
6069
  state._bodyScreenTop = lines.length - bodyH;
5651
6070
  // How many blank padding rows precede the first body row on screen. When
5652
6071
  // the transcript is shorter than the body, `start` is negative and those
@@ -5924,6 +6343,9 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5924
6343
  // `qs.index` selects a question; `qs.index === qs.items.length` selects the
5925
6344
  // Other tab. The current tab is highlighted (background).
5926
6345
  const onOtherTab = qs.index >= qs.items.length;
6346
+ // Where each tab lands inside the box, so a CLICK can select it. The tab bar is the only way to reach an
6347
+ // earlier question with the mouse; without these the labels were plain text and a click did nothing.
6348
+ const tabHits = [];
5927
6349
  {
5928
6350
  let tabs = '';
5929
6351
  const tabName = (i) => {
@@ -5933,7 +6355,10 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5933
6355
  for (let i = 0; i <= qs.items.length; i++) {
5934
6356
  const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
5935
6357
  const on = i === qs.index;
5936
- tabs += on ? col(' ' + name + ' ', C.selBg + C.cyan + C.bold) : col(' ' + name + ' ', C.gray);
6358
+ const colStart = visualCol(tabs);
6359
+ const label = ' ' + name + ' ';
6360
+ tabs += on ? col(label, C.selBg + C.cyan + C.bold) : col(label, C.gray);
6361
+ tabHits.push({ index: i, colStart, colEnd: colStart + visualCol(label) - 1 });
5937
6362
  tabs += ' ';
5938
6363
  }
5939
6364
  box.push(boxRow(fitAnsi(tabs, innerW - 2)));
@@ -6005,7 +6430,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6005
6430
  const boxTop = lines.length;
6006
6431
  for (const b of box) lines.push(b);
6007
6432
  lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
6008
- itemRows.forEach((r) => addHit(boxTop + r.boxIdx, 0, w - 1, { kind: 'questionItem', index: r.index }));
6433
+ // THE TAB LABELS ARE CLICKABLE. The tab bar is the box's first row, so each label's column span becomes a
6434
+ // hit that switches to that question — the mouse equivalent of Tab. Without it the labels were plain text
6435
+ // and clicking an earlier question did nothing.
6436
+ tabHits.forEach((t) => addHit(boxTop, t.colStart + 2, t.colEnd + 2, { kind: 'questionTab', index: t.index }));
6437
+ // THE OPTION'S OWN COLUMNS, NOT THE WHOLE ROW.
6438
+ //
6439
+ // `addHit(row, 0, w - 1)` made the hover tint cover the box's left and right borders too, so pointing at
6440
+ // an option lit the frame around it. The hit starts inside the border and stops before the closing one.
6441
+ itemRows.forEach((r) => addHit(boxTop + r.boxIdx, 2, Math.max(2, w - 3), { kind: 'questionItem', index: r.index }));
6009
6442
  if (caretInBox >= 0) {
6010
6443
  // The box is mid-frame (chat above, composer below), NOT pinned to the top,
6011
6444
  // so its caret is recorded as an absolute frame row and corrected with the
@@ -6229,24 +6662,34 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6229
6662
  }
6230
6663
 
6231
6664
  if (!takeover && state.todos && state.todos.length) {
6232
- // The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
6233
- // the mouse handler can hover/press it; the row offset is corrected to final
6234
- // screen coordinates by composeFrame's hitbox pass (like every other hit).
6665
+ // ONLY THE HEADER IS THE DRAG HANDLE, not the whole panel.
6666
+ //
6667
+ // This registered `addHit(todoTopRow, 0, w - 1)` — the entire width of the panel's first row — and the
6668
+ // queue below did the same. The hit list is searched LAST-FIRST, so the queue's full-width row shadowed
6669
+ // every control that happened to land on it: a slash-menu item, a picker row, a status segment. "It clicks
6670
+ // into the queue instead" is exactly that. The handle is the rule and its title line.
6235
6671
  const todoTopRow = lines.length;
6236
- addHit(todoTopRow, 0, w - 1, { kind: 'todoResize' });
6237
- for (const l of renderTodoPanel(state, w, state.todoResizeHover, state.todoResizeDrag)) lines.push(l);
6672
+ const todoPanel = renderTodoPanel(state, w, state.todoResizeHover, state.todoResizeDrag);
6673
+ // The rule and the ` Todo` line are the grab area; the item rows below are not.
6674
+ for (let r = 0; r < Math.min(2, todoPanel.length); r++) {
6675
+ addHit(todoTopRow + r, 0, w - 1, { kind: 'todoResize' });
6676
+ }
6677
+ for (const l of todoPanel) lines.push(l);
6238
6678
  }
6239
6679
 
6240
6680
  // Queued (not yet sent) messages, directly above the composer: these are what
6241
6681
  // Ctrl-S steers into the running turn. See renderQueuePanel.
6242
6682
  if (!takeover && state.queued && state.queued.length) {
6243
- // The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
6244
- // the mouse handler can hover/press it.
6683
+ // Same rule as the todo panel: the header is the handle, not every row.
6245
6684
  const queueTopRow = lines.length;
6246
- addHit(queueTopRow, 0, w - 1, { kind: 'queueResize' });
6247
- for (const l of renderQueuePanel(state, w, state.queueResizeHover, state.queueResizeDrag)) lines.push(l);
6685
+ const queuePanel = renderQueuePanel(state, w, state.queueResizeHover, state.queueResizeDrag);
6686
+ for (let r = 0; r < Math.min(2, queuePanel.length); r++) {
6687
+ addHit(queueTopRow + r, 0, w - 1, { kind: 'queueResize' });
6688
+ }
6689
+ for (const l of queuePanel) lines.push(l);
6248
6690
  }
6249
6691
 
6692
+
6250
6693
  // Side-thread box (/btw), docked directly above the composer — the placement kimi
6251
6694
  // uses (its panelContainer sits over the editor with `connectedAbove = true`), so
6252
6695
  // the conversation being asked about stays visible while you keep typing.
@@ -6602,7 +7045,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6602
7045
  // this point on, column N of a row IS screen column N. The final `fitAnsi` pass then
6603
7046
  // has nothing left to do for width (an `w`-wide row is returned unchanged).
6604
7047
 
6605
- for (let i = 0; i < lines.length; i++) lines[i] = fitAnsi(lines[i] == null ? '' : lines[i], w);
7048
+ // AN IMAGE ROW IS LEFT ALONE.
7049
+ //
7050
+ // `fitAnsi` pads and truncates to the panel width, and a Sixel payload is thousands of characters: this
7051
+ // pass cut every bitmap down to its header plus a few bytes, which is what the terminal then failed to
7052
+ // parse. The row stays exactly as the markdown renderer produced it.
7053
+ for (let i = 0; i < lines.length; i++) {
7054
+ const l = lines[i] == null ? '' : lines[i];
7055
+ lines[i] = isImageRow(l) ? l : fitAnsi(l, w);
7056
+ }
6606
7057
  let topPad = 0;
6607
7058
  if (lines.length < h) { topPad = h - lines.length; }
6608
7059
  while (lines.length < h) lines.unshift(' '.repeat(w));
@@ -6747,7 +7198,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6747
7198
  }
6748
7199
  }
6749
7200
 
6750
- let padded = lines.map((l) => fitAnsi(l, w));
7201
+ let padded = lines.map((l) => (isImageRow(l) ? l : fitAnsi(l, w)));
6751
7202
  // Mixin seam: beforeRender - plugins may rewrite the painted lines. Runs before the
6752
7203
  // ANSI string is built so the change actually reaches the screen.
6753
7204
  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 +7214,66 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6763
7214
 
6764
7215
  const ansi = `\x1b[H` + padded.join('\n')
6765
7216
  + `\x1b[${cursor.row + 1};${cursor.col + 1}H` + cursorShape;
6766
- let _frame = { ansi, lines: padded, cursor, cursorVisible, width: w, height: h,
7217
+ // Lift every image payload out of the text rows. What remains in `padded` is blank, which is what the
7218
+ // rows under a bitmap should be: the terminal draws the picture there, and text must not follow it.
7219
+ // How many body rows sit above the window. The painter compares content and addresses the screen by
7220
+ // window row, so it needs both numbers; `state._bodyTop` is the body index of the window's first row.
7221
+ const viewportTop = Math.max(0, (state && state._bodyTop) || 0);
7222
+ // Lift the pictures out of BODY rows: that is the grid the painter compares. `padded` is the window cut
7223
+ // from the body afterwards, so its own copies of those rows have to be blanked as well.
7224
+ const images = [];
7225
+ // Where body row `_bodyTop` sits on the final screen. A body row only equals its screen row when the
7226
+ // window starts at the top of the transcript, so the offset is carried here rather than assumed: a picture
7227
+ // placed at its body index lands outside the frame in any session longer than the window, which is what
7228
+ // made a formula look like it had never been drawn.
7229
+ const bodyOrigin = state && state._bodyScreenTop != null
7230
+ ? { screenTop: state._bodyScreenTop, bodyTop: state._bodyTop != null ? state._bodyTop : 0 }
7231
+ : null;
7232
+ for (let i = 0; i < body.length; i++) {
7233
+ if (!isImageRow(body[i])) continue;
7234
+ const parsed = parseImageRow(body[i]);
7235
+ if (!parsed) continue;
7236
+ if (bodyOrigin) {
7237
+ const screenRow = bodyOrigin.screenTop + (i - bodyOrigin.bodyTop);
7238
+ // A PICTURE MUST STAY INSIDE THE TRANSCRIPT PANEL.
7239
+ //
7240
+ // `h` is the whole screen, but the transcript only occupies `screenTop .. screenTop + bodyH`; below it
7241
+ // are the composer and the status line. A picture whose last row ran past that boundary was drawn over
7242
+ // the composer — a formula sliding down into the input box as the view scrolled, which is what "it goes
7243
+ // to the composer before it leaves" was. The rows are clipped to the panel here, and only a picture that
7244
+ // still has a visible row inside it is kept.
7245
+ const panelTop = Math.max(0, bodyOrigin.screenTop);
7246
+ const panelBottom = Math.min(h, bodyOrigin.screenTop + bodyH);
7247
+ const firstRow = Math.max(screenRow, panelTop);
7248
+ const lastRow = Math.min(screenRow + parsed.rows, panelBottom);
7249
+ if (lastRow > firstRow) {
7250
+ // The column is part of the placement: every transcript row opens with a one-column pad. A picture
7251
+ // clipped at the top keeps its `row` negative so the painter knows how many pixel rows to cut.
7252
+ images.push({
7253
+ row: screenRow,
7254
+ rows: parsed.rows,
7255
+ cols: parsed.cols,
7256
+ seq: parsed.sequence,
7257
+ col: 1,
7258
+ // What produced the payload, so the painter can regenerate it from a later pixel row when the
7259
+ // picture straddles the top of the window. See `placeImage`.
7260
+ source: parsed.source,
7261
+ // The rows this picture may occupy, so the painter can clip the bottom as well as the top.
7262
+ clipTop: panelTop,
7263
+ clipBottom: panelBottom,
7264
+ });
7265
+ }
7266
+ }
7267
+ body[i] = '';
7268
+ for (let r = 1; r < parsed.rows && i + r < body.length; r++) body[i + r] = '';
7269
+ }
7270
+
7271
+ // The same rows, in the window array.
7272
+ for (let i = 0; i < padded.length; i++) {
7273
+ if (isImageRow(padded[i])) padded[i] = '';
7274
+ }
7275
+
7276
+ let _frame = { ansi, lines: padded, body, images, viewportTop, cursor, cursorVisible, width: w, height: h,
6767
7277
  cursorRow: cursor.row, cursorCol: cursor.col, cursorShape, hitboxes, composerMeta: composer.meta };
6768
7278
  // Mixin seam: afterRender - plugins may modify the final frame (overlay, banner).
6769
7279
  try { const out = runPatchSync('afterRender', { frame: _frame, state }, (c) => c); if (out && out.frame) _frame = out.frame; } catch (e) {}
@@ -6797,7 +7307,176 @@ const CR = '\r';
6797
7307
  // rewrites 30 rows lands as one update instead of 30 visible steps.
6798
7308
  const SYNC_BEGIN = '\x1b[?2026h';
6799
7309
  const SYNC_END = '\x1b[?2026l';
6800
- export function diffFrame(prev, next) {
7310
+
7311
+ // A FORMULA CROPPED AT A PIXEL ROW IS REGENERATED FROM ITS TEX.
7312
+ //
7313
+ // The painter crops a picture that straddles the top of the window, and it must crop on a PIXEL row, not on a
7314
+ // six-pixel band boundary: a band crop always leaves 0..5 pixels of the picture above the window, so the
7315
+ // formula shifts by up to a quarter of a cell as the view scrolls and snaps back when the leftover is zero.
7316
+ // Only the source can produce a raster cut at an arbitrary row, and only this module knows how to typeset —
7317
+ // so the function is installed here rather than imported there.
7318
+ setImageRegenerator((source, skipTop, maxColumns, maxHeight) => {
7319
+ const capable = mathImageCapable();
7320
+ if (!capable) return null;
7321
+ const image = encodeTexImage(source, {
7322
+ protocol: capable.protocol,
7323
+ cell: capable.cell,
7324
+ colour: MATH_INK,
7325
+ skipTop,
7326
+ maxWidth: maxColumns,
7327
+ maxHeight,
7328
+ });
7329
+ return image ? image.sequence : null;
7330
+ });
7331
+
7332
+ /**
7333
+ * Turn a frame into the bytes that update the screen.
7334
+ *
7335
+ * `resendImages` is false for a PERIODIC full repaint. A bitmap only has to be re-sent when the terminal
7336
+ * has lost it — the first paint, or after a resize. Windows Terminal moves the screen every time one is
7337
+ * drawn, so re-sending on the once-a-second repaint pushed the whole interface down once a second; that
7338
+ * is what left the composer repeated down the screen.
7339
+ */
7340
+ // ---- the cell-grid painter ----------------------------------------------
7341
+
7342
+ // The frame composer still produces rows of styled strings, because every surface here was written against
7343
+ // that shape. This layer paints those rows into a cell grid and paints the grid differentially, which is
7344
+ // what makes two things possible that rows cannot express: a change that rewrites only the affected
7345
+ // columns (18 bytes instead of a whole row), and a bitmap treated as a RECTANGLE with no text in it.
7346
+ //
7347
+ // The grid is kept across frames. Its pools are session-lived on purpose: an interned style id stays valid
7348
+ // as long as the process, so a comparison is an integer compare.
7349
+ const screenState = screenPools();
7350
+ let prevScreen = null;
7351
+
7352
+ /**
7353
+ * Paint one frame through the cell grid.
7354
+ *
7355
+ * @param {object} frame Output of `composeFrame`.
7356
+ * @param {boolean} resendImages Whether bitmaps must be re-sent this paint.
7357
+ * @returns {string} The bytes to write.
7358
+ */
7359
+ export function paintCellFrame(frame, resendImages) {
7360
+ const rows = frame.lines || [];
7361
+ const bodyRows = frame.body || rows;
7362
+ const width = frame.width || 80;
7363
+ const height = Math.max(1, frame.height || rows.length);
7364
+ const images = frame.images || [];
7365
+
7366
+ // THE `shownImages` RECORD IS NEVER CLEARED HERE.
7367
+ //
7368
+ // It says which rectangles the terminal currently has pictures in, and it is the only thing an ERASE can be
7369
+ // computed from: clearing it means the old pixels have no owner and can never be removed. What
7370
+ // `resendImages` controls is whether the pictures are DRAWN again — and since every picture is now drawn on
7371
+ // every frame, the flag only has to keep the reconciliation from treating a stale screen as authoritative.
7372
+
7373
+ // WHICH PICTURES THIS FRAME CAN ACTUALLY HAVE ON SCREEN, decided BEFORE any cell is touched.
7374
+ //
7375
+ // A picture that will not be drawn must leave its cells to the TEXT. Clearing them for a picture that
7376
+ // never arrives is what produced a blank rectangle over the transcript, the scrollbar and the composer:
7377
+ // the row was blanked, no pixels were painted, and the text that belonged there had already been thrown
7378
+ // away. A refused picture is therefore NOT a hole — it is the Unicode layout, which `composeFrame` has
7379
+ // already put in those cells.
7380
+ //
7381
+ // `resendImages` no longer filters anything: EVERY picture in the window is queued on every frame. What the
7382
+ // flag still does is survive to `diffScreens` as the reason the caller expects a full draw.
7383
+ const drawable = [];
7384
+ let budget = IMAGE_FRAME_BUDGET;
7385
+ for (const img of images.slice(0, IMAGE_FRAME_MAX)) {
7386
+ // Visible inside BOTH the screen and the transcript panel: a picture clipped away at the bottom no
7387
+ // longer reaches into the composer.
7388
+ const span = placementRows(img, height);
7389
+ if (span.to <= span.from) continue;
7390
+ if (img.seq.length > budget) continue; // over this frame's payload budget
7391
+ budget -= img.seq.length;
7392
+ drawable.push(img);
7393
+ }
7394
+
7395
+ const screen = createScreen(width, height, screenState.charPool, screenState.stylePool);
7396
+ paintRows(screen, rows, screenState.stylePool);
7397
+ // Blank ONLY the cells a picture will cover, and only its own columns: the last column of these rows is
7398
+ // the scrollbar, and the rest of the row still belongs to the interface.
7399
+ for (const img of drawable) {
7400
+ const span = placementRows(img, height);
7401
+ const from = Math.min(width, Math.max(0, img.col || 0) + 1);
7402
+ const to = Math.min(width, from + Math.max(0, img.cols) - 1);
7403
+ const w = to - from;
7404
+ const h = span.to - span.from;
7405
+ if (w > 0 && h > 0) screen.clear(from, span.from, w, h, 0);
7406
+ }
7407
+
7408
+ // A PICTURE IS RECONCILED, NOT DRAWN HERE.
7409
+ //
7410
+ // `diffScreens` owns the decision (see its header; dsh-tui's src/ink/sixel-graphics.ts reaches the same
7411
+ // shape): erase a placement that changed or MOVED, draw the one replacing it in the SAME write, send
7412
+ // nothing for a placement whose raster and position both still match.
7413
+ const cursorRow = (frame.cursorRow || 0);
7414
+ const cursor = `\x1b[${Math.max(1, cursorRow + 1)};${(frame.cursorCol || 0) + 1}H` + (frame.cursorShape || '');
7415
+ const out = diffScreens(prevScreen, screen, drawable.map((img) => ({ ...img, col: img.col || 0 })), {
7416
+ cursor,
7417
+ shown: shownImages,
7418
+ budget: IMAGE_FRAME_BUDGET,
7419
+ maxImages: IMAGE_FRAME_MAX,
7420
+ cellHeight: mathCellHeight(),
7421
+ before: syncOutputUsable() ? SYNC_BEGIN : '',
7422
+ after: syncOutputUsable() ? SYNC_END : '',
7423
+ });
7424
+
7425
+
7426
+
7427
+ // The rows the pictures own THIS frame, so the NEXT frame knows which rows to repaint as text once a
7428
+ // picture moves away. Taken from `drawable` — this frame's placements — and not from `shownImages`, which
7429
+ // is replaced inside the reconciliation and by this point already describes the frame that just ran. Reading
7430
+ // the record there made `imageRows` lag one frame behind: the rows a picture occupied were never registered
7431
+ // as pictures' rows, so when it scrolled away the text was diffed against a previous frame that also had no
7432
+ // text there — the row was left alone and the old glyphs stayed under the new ones.
7433
+ const owned = [];
7434
+ for (const img of drawable) {
7435
+ const span = placementRows(img, height);
7436
+ for (let y = span.from; y < span.to; y++) owned.push(y);
7437
+ }
7438
+ screen.imageRows = owned;
7439
+ screen.bodyRows = bodyRows.length;
7440
+ prevScreen = screen;
7441
+ return out;
7442
+ }
7443
+
7444
+ /**
7445
+ * What the terminal is currently believed to be showing: one entry per placement, keyed by raster AND
7446
+ * position.
7447
+ *
7448
+ * THE POSITION IS PART OF THE IDENTITY, which is the correction that matters here. Keying on the raster
7449
+ * alone treated a formula that had scrolled one row up as "already on screen", so its new row was never
7450
+ * drawn and its old row was never erased — a blank rectangle at the new position, stale pixels at the old
7451
+ * one, and the text underneath left unwritten.
7452
+ *
7453
+ * `diffScreens` reads this map and rewrites it each paint, dropping the entries the frame no longer wants,
7454
+ * so it cannot grow without bound.
7455
+ */
7456
+ const shownImages = new Map();
7457
+
7458
+ /**
7459
+ * Bytes of picture payload one frame may carry.
7460
+ *
7461
+ * The payload is the expensive half of a frame and the only part that can push the terminal's screen, so a
7462
+ * frame that would exceed this sends the first few pictures that fit and leaves the rest to their text
7463
+ * fallback. A pathological reply — fifty matrices — degrades instead of hanging the terminal.
7464
+ */
7465
+ const IMAGE_FRAME_BUDGET = 256 * 1024;
7466
+ /** At most this many pictures in one frame, for the same reason the byte budget exists. */
7467
+ const IMAGE_FRAME_MAX = 12;
7468
+
7469
+ /** Forget every picture the terminal is believed to show, so the next paint sends them all again. */
7470
+ export function resetShownImages() {
7471
+ shownImages.clear();
7472
+ }
7473
+
7474
+ /** Forget the previous grid, so the next paint is a full one. */
7475
+ export function resetCellFrame() {
7476
+ prevScreen = null;
7477
+ }
7478
+
7479
+ export function diffFrame(prev, next, resendImages = true) {
6801
7480
  let shape = '';
6802
7481
  if (!prev || prev.cursorShape !== next.cursorShape) shape = next.cursorShape;
6803
7482
  const pos = `\x1b[${next.cursorRow + 1};${next.cursorCol + 1}H`;
@@ -6812,7 +7491,23 @@ export function diffFrame(prev, next) {
6812
7491
  const sizeChanged = !prev || prev.width !== next.width || prev.height !== next.height;
6813
7492
  if (sizeChanged) {
6814
7493
  let out = before;
6815
- for (let i = 0; i < next.lines.length; i++) out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7494
+ for (let i = 0; i < next.lines.length; i++) {
7495
+ if (isImageRow(next.lines[i])) {
7496
+ const parsed = parseImageRow(next.lines[i]);
7497
+ if (!resendImages) {
7498
+ // The terminal is still showing it; only skip the rows it occupies so they are not blanked.
7499
+ if (parsed && parsed.rows > 1) i += parsed.rows - 1;
7500
+ continue;
7501
+ }
7502
+ const placed = imageBytes(next.lines[i], i, '\x1b[0m');
7503
+ if (placed) {
7504
+ out += placed.bytes;
7505
+ i += Math.max(0, placed.covered - 1);
7506
+ continue;
7507
+ }
7508
+ }
7509
+ out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7510
+ }
6816
7511
  out += after + pos + shape;
6817
7512
  return out;
6818
7513
  }
@@ -6840,8 +7535,27 @@ export function diffFrame(prev, next) {
6840
7535
  // scrolled everything up by a row and the next `ESC[2K` erased the composer's
6841
7536
  // top border — "Working… gets written on the next line and overwrites the
6842
7537
  // 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;
7538
+ // A bitmap covers rows the text diff cannot see: extend the rewritten span to include them, or the
7539
+ // rows beneath an image keep the previous frame's content and the screen accumulates fragments.
7540
+ let reach = Math.min(next.lines.length, last + 1);
7541
+ for (let i = 0; i < next.lines.length; i++) {
7542
+ if (!isImageRow(next.lines[i])) continue;
7543
+ const p = parseImageRow(next.lines[i]);
7544
+ if (p) reach = Math.max(reach, Math.min(next.lines.length, i + p.rows));
7545
+ }
7546
+
7547
+ for (let i = first; i < reach; i++) {
7548
+ if (isImageRow(next.lines[i])) {
7549
+ const placed = imageBytes(next.lines[i], i, '\x1b[0m');
7550
+ if (placed) {
7551
+ out += placed.bytes;
7552
+ i += Math.max(0, placed.covered - 1);
7553
+ continue;
7554
+ }
7555
+ }
7556
+ out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i] + CR;
7557
+ }
7558
+
6845
7559
  // No "clear the trailing rows" pass is needed: composeFrame always returns
6846
7560
  // EXACTLY `rows` lines (both frames here share the same height, otherwise the
6847
7561
  // sizeChanged path above would have run), so a shrinking viewport is already
@@ -6918,8 +7632,8 @@ export function makeState({ cfg, session, opts }) {
6918
7632
  // was re-picked (setEffort is the only other writer, and it sets both).
6919
7633
  const effort = (session && session.effort) || cfg.effort || (cfg.reasoning ? 'on' : 'off');
6920
7634
  return {
6921
- // No model chosen yet: the interface says so instead of refusing to start.
6922
- modelMissing: !!(opts && opts.modelMissing),
7635
+ // No model chosen yet: the interface says so instead of refusing to start.
7636
+ modelMissing: !!(opts && opts.modelMissing),
6923
7637
  workspace: cwd,
6924
7638
  cfg,
6925
7639
  // The SAME session local this function already reads for `effort`, kept on the state
@@ -6961,9 +7675,25 @@ export function makeState({ cfg, session, opts }) {
6961
7675
  cwd,
6962
7676
  cwdOverride: null,
6963
7677
  tip: '',
6964
- ctxPercent: 0,
6965
- ctxTokens: 0,
6966
- ctxMax: cfg.maxContextTokens || 512000,
7678
+ // THE GAUGE STARTS FROM THE SESSION, NOT FROM ZERO.
7679
+ //
7680
+ // These were hard-coded to `false`/`0`, and the only code that ever read the saved figures was the
7681
+ // `/sessions` resume branch — which a plain `hncode --continue` never runs. So every restart showed the
7682
+ // local estimate until a provider answered, even though the reported size had been saved with the
7683
+ // session all along. Reading it here covers every entry point: `--continue`, `--session <id>`, and the
7684
+ // picker.
7685
+ ctxPercent: (session && session.ctxReportedOnce && Number.isFinite(session.ctxTokens)
7686
+ && Number.isFinite(session.ctxMax) && session.ctxMax > 0)
7687
+ ? usagePercent(session.ctxTokens, session.ctxMax)
7688
+ : 0,
7689
+ // Set once the endpoint reports a real context size; from then on the local estimate is not used.
7690
+ ctxReportedOnce: !!(session && session.ctxReportedOnce && Number.isFinite(session.ctxTokens)),
7691
+ ctxTokens: (session && session.ctxReportedOnce && Number.isFinite(session.ctxTokens))
7692
+ ? session.ctxTokens
7693
+ : 0,
7694
+ ctxMax: (session && Number.isFinite(session.ctxMax) && session.ctxMax > 0)
7695
+ ? session.ctxMax
7696
+ : (cfg.maxContextTokens || 512000),
6967
7697
  rounds: (session && session.rounds) || 0,
6968
7698
  turnStart: 0,
6969
7699
  steps: (session && session.steps) || 0,
@@ -8476,16 +9206,23 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8476
9206
 
8477
9207
  // Update context gauge based on restored session messages.
8478
9208
  //
8479
- // THE SAME FORMULA AS A LIVE STEP: messages PLUS the tool schemas. This used to count the
8480
- // messages alone, so the gauge stepped by a fixed amount whenever a restore and a live step
8481
- // alternated — a visible jump that was two formulas disagreeing, not noise.
9209
+ // THE REPORTED SIZE COMES BACK WITH THE SESSION. `session.ctxTokens` / `ctxReportedOnce` are
9210
+ // written whenever a provider reports a real figure, and they were never read back on resume — so
9211
+ // a restarted session showed the ESTIMATE until the next turn sent a request, even though the
9212
+ // exact number was sitting in the saved session the whole time.
8482
9213
  //
8483
- // `dispatch` is module-level and cannot reach startTUI's `setCtxTokens`, so the expression is
8484
- // repeated here rather than the call. Keep the two identical.
8485
- const approx = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
8486
- state.ctxTokens = approx;
9214
+ // Only a session that never got a report falls through to the estimate, and then to the same
9215
+ // formula a live step uses: messages PLUS the tool schemas.
9216
+ if (session.ctxReportedOnce && Number.isFinite(session.ctxTokens)) {
9217
+ state.ctxTokens = session.ctxTokens;
9218
+ state.ctxReportedOnce = true;
9219
+ } else {
9220
+ state.ctxTokens = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
9221
+ state.ctxReportedOnce = false;
9222
+ }
8487
9223
  state.ctxMax = cfg.maxContextTokens || state.ctxMax;
8488
- state.ctxPercent = usagePercent(approx, state.ctxMax);
9224
+ // THE PERCENTAGE MUST BE COMPUTED FROM THE SAME NUMBER IT IS SHOWN WITH.
9225
+ state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax);
8489
9226
 
8490
9227
  const dur = session.lastTurnMs ? ` · last turn ${fmtDuration(session.lastTurnMs)}` : '';
8491
9228
  app(`Resumed ${meta.id} (${meta.title || 'untitled'}) · ${(session.messages || []).length} messages.${dur}`);
@@ -8806,8 +9543,23 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8806
9543
  // _conv: true}` instead of rows: a message with a role of `undefined`, which
8807
9544
  // renders as blank. That is what made the transcript go empty after a manual
8808
9545
  // /compact. Flat-map so every produced row is a row.
9546
+ // THE WHOLE TRANSCRIPT IS REBUILT, NOT APPENDED TO.
9547
+ //
9548
+ // The old form kept `state.chat.filter((m) => !m._conv)` at the head and appended the compacted history
9549
+ // after it — which assumed every pre-existing row carried `_conv`. Rows that came from a RESUME
9550
+ // (`reconstructChat`) do not, so a session that had been restarted kept its whole old transcript above
9551
+ // the summary while the compacted messages were added below: the old thinking blocks reappeared at the
9552
+ // top, as if the compaction had not happened.
9553
+ //
9554
+ // What is kept is the PROMPT that asked for the compaction — the last user row — so the command that
9555
+ // caused this stays visible above the summary it produced. Everything else comes from `kept`.
9556
+ const promptRows = [];
9557
+ for (let i = state.chat.length - 1; i >= 0; i--) {
9558
+ const m = state.chat[i];
9559
+ if (m && m.role === 'user' && !m._conv) { promptRows.push(m); break; }
9560
+ }
8809
9561
  state.chat = [
8810
- ...state.chat.filter((m) => !m._conv),
9562
+ ...promptRows,
8811
9563
  { role: 'user', text: compactedSummary, _conv: true },
8812
9564
  ...kept.flatMap((m) => convRowFor(m).map((r) => ({ ...r, _conv: true }))),
8813
9565
  ];
@@ -8815,8 +9567,10 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8815
9567
  saveSession(session);
8816
9568
  // Same formula as everywhere else: messages PLUS the tool schemas.
8817
9569
  state.tokens = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
8818
- state.ctxTokens = state.tokens;
8819
- state.ctxPercent = usagePercent(state.tokens, state.ctxMax || maxCtx);
9570
+ if (!state.ctxReportedOnce) state.ctxTokens = state.tokens;
9571
+ // Same rule as everywhere else: the percentage comes from the number the row prints, not from the
9572
+ // estimate. Using `state.tokens` here made a compacted session show `696k/1M` beside 57%.
9573
+ state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || maxCtx);
8820
9574
  // The transcript was just replaced with a much shorter one. Drop the scroll
8821
9575
  // anchor (as /sessions and /move do) so the next paint seeds a fresh one and
8822
9576
  // the view lands on the newest rows instead of compensating for a row delta
@@ -9272,21 +10026,30 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9272
10026
 
9273
10027
  case 'goal': {
9274
10028
  const a = raw.toLowerCase();
10029
+ // EVERY WRITE GOES THROUGH THE STORE the Goal tool reads, so /goal and the tool can never disagree
10030
+ // about the objective. `persistState` keeps it across a restart; the tool's own set() does the same.
10031
+ const applyGoal = (value, paused) => {
10032
+ state.objective = String(value || '');
10033
+ state.goalPaused = !!paused;
10034
+ sessHost.persistState();
10035
+ renderFrame();
10036
+ };
9275
10037
  if (!raw || a === 'status') {
9276
10038
  if (state.objective) sayPanel(`Goal: ${state.objective}\nStatus: ${state.goalPaused ? 'paused' : 'active'}`);
9277
10039
  else say('No goal set. Start one with /goal <objective>.');
9278
10040
  return;
9279
10041
  }
9280
- if (a === 'pause') { state.goalPaused = true; app('Goal paused. Use /goal resume to continue.'); return; }
10042
+ if (a === 'pause') { applyGoal(state.objective, true); app('Goal paused. Use /goal resume to continue.'); return; }
9281
10043
  if (a === 'resume') {
9282
10044
  if (!state.objective) { app('No goal to resume.'); return; }
9283
- state.goalPaused = false; app('Goal resumed.');
10045
+ applyGoal(state.objective, false);
10046
+ app('Goal resumed.');
9284
10047
  sendPrompt('Resume the active goal.');
9285
10048
  return;
9286
10049
  }
9287
- if (a === 'cancel') { state.objective = ''; state.goalPaused = false; app('Goal cancelled.'); return; }
9288
- if (a.startsWith('replace ')) { state.objective = raw.slice(8).trim(); app('Goal replaced.'); return; }
9289
- state.objective = raw; state.goalPaused = false;
10050
+ if (a === 'cancel') { applyGoal('', false); app('Goal cancelled.'); return; }
10051
+ if (a.startsWith('replace ')) { applyGoal(raw.slice(8).trim(), false); app('Goal replaced.'); return; }
10052
+ applyGoal(raw, false);
9290
10053
  app(`Goal set: ${raw}`);
9291
10054
  sendPrompt(raw);
9292
10055
  return;
@@ -10214,7 +10977,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10214
10977
  //
10215
10978
  // Plus the tool schemas, like every other writer: `/trim` counts messages, and the request also
10216
10979
  // carries the tools, so leaving them out dropped the gauge by that much more than was freed.
10217
- state.ctxTokens = after + (cfg.toolDefTokens || 0);
10980
+ if (!state.ctxReportedOnce) state.ctxTokens = after + (cfg.toolDefTokens || 0);
10218
10981
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || cfg.maxContextTokens || 1);
10219
10982
  if (session) {
10220
10983
  try {
@@ -12032,45 +12795,45 @@ function registerProvider(state, cfg, h, name, baseUrl, apiKey, protocol, typeLa
12032
12795
  fetchAndRegisterModels(state, cfg, name, h);
12033
12796
  }
12034
12797
 
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
- }
12798
+ async function fetchAndRegisterModels(state, cfg, providerName, h) {
12799
+ const p = (cfg.raw.providers || {})[providerName] || {};
12800
+ const baseUrl = p.base_url || p.baseUrl || '';
12801
+ if (!baseUrl) return;
12802
+ h.notice(`Discovering models for ${providerName}…`, 'info');
12803
+ const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
12804
+ // THE FETCH MUST SUCCEED BEFORE ANYTHING IS REMOVED.
12805
+ //
12806
+ // Deleting first and fetching second means an unreachable endpoint leaves the provider with no models at
12807
+ // all. Everything below runs only after a non-empty result.
12808
+ if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
12809
+
12810
+ cfg.raw.models = cfg.raw.models || {};
12811
+ // Snapshot what this provider had, so the merge below can keep fields the source does not report.
12812
+ const prevByKey = new Map();
12813
+ for (const key of Object.keys(cfg.raw.models)) {
12814
+ if (key.split('/')[0] !== providerName) continue;
12815
+ prevByKey.set(key, cfg.raw.models[key]);
12816
+ delete cfg.raw.models[key];
12817
+ }
12818
+
12819
+ // REPLACE THE LIST WITH WHAT WAS JUST FETCHED.
12820
+ //
12821
+ // A model the endpoint no longer reports disappears, and a renamed one does not appear twice. Merging
12822
+ // over the old entries kept both for ever, which is what made Alt+R look like it did nothing.
12823
+ const kept = [];
12824
+ for (const m of models) {
12825
+ const key = modelKey(providerName, m.id);
12826
+ // Merge over the SNAPSHOT: the live entries were just deleted, so reading them yields undefined and
12827
+ // the merge folds nothing — every field the source omits (context_length, pricing) would be wiped.
12828
+ const entry = mergeRefreshedModelEntry(providerName, m, prevByKey.get(key));
12829
+ cfg.raw.models[key] = entry;
12830
+ kept.push({ ...entry, id: m.id });
12831
+ }
12832
+ // Rewrite the provider's TOML blocks from that list. `addModel` appends one block per model and returns
12833
+ // early when one already exists, so calling it here left the removed entries on disk.
12834
+ try { replaceProviderModels(providerName, kept); } catch { /* the in-memory list is still correct */ }
12835
+ h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
12836
+ }
12074
12837
 
12075
12838
  // Merge ONE refreshed model's data over its previous config entry.
12076
12839
  //
@@ -12319,6 +13082,21 @@ export function tokenize(str) {
12319
13082
  }
12320
13083
  i += ku[0].length; continue;
12321
13084
  }
13085
+ // KITTY GRAPHICS REPLY. The terminal acknowledges an upload with its own APC:
13086
+ //
13087
+ // ESC _ G i=31;OK ESC \
13088
+ //
13089
+ // Nothing else consumes it, and an APC that reaches the key parser is ESC followed by ordinary
13090
+ // characters — so `Gi=31;OK` is typed into the composer, one letter at a time, every time the terminal
13091
+ // answers. Recognising it here and turning it into a token means it is CONSUMED: `handleKey` sees
13092
+ // `kittyReply` and ignores it.
13093
+ if (str[i + 1] === '_') {
13094
+ const end = str.indexOf('\x1b\\', i + 2);
13095
+ if (end === -1) break; // incomplete: wait for the rest
13096
+ out.push({ kittyReply: str.slice(i + 2, end) });
13097
+ i = end + 2;
13098
+ continue;
13099
+ }
12322
13100
  if (str[i + 1] === '[') {
12323
13101
  if (str[i + 2] === '<') {
12324
13102
  const mm = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(str.slice(i));
@@ -12383,21 +13161,24 @@ export function tokenize(str) {
12383
13161
  out.push({ key: base });
12384
13162
  i = j + 1;
12385
13163
  } else if (str[i + 1] === 'O') {
13164
+ // SS3: the application-cursor form of the arrows and function keys.
12386
13165
  const f = str[i + 2];
12387
13166
  if (f === undefined) break;
12388
- out.push({ key: f === 'P' ? 'f1' : 'escape' });
13167
+ const ss3 = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end',
13168
+ P: 'f1', Q: 'f2', R: 'f3', S: 'f4' }[f];
13169
+ out.push({ key: ss3 || 'escape' });
12389
13170
  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
- }
13171
+ } else {
13172
+ // `ESC <printable>` is Alt+that key. A second ESC stays a plain Escape.
13173
+ const altNext = str[i + 1];
13174
+ if (altNext && altNext >= '!' && altNext <= '~') {
13175
+ out.push({ key: 'alt-' + altNext.toLowerCase() });
13176
+ i += 2;
13177
+ continue;
13178
+ }
13179
+ out.push({ key: 'escape' });
13180
+ i++;
13181
+ }
12401
13182
  } else if (c === '\r') {
12402
13183
  out.push({ key: 'enter' });
12403
13184
  i++;
@@ -12972,6 +13753,17 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12972
13753
  state._turnSteps = (state._turnSteps || 0) + 1;
12973
13754
  state.steps = (state._stepsBase || 0) + state._turnSteps;
12974
13755
  if (session) session.steps = state.steps;
13756
+ // THE GAUGE MOVES WITH EVERY STEP, not only when the provider answers.
13757
+ //
13758
+ // A provider reports usage with its REPLY, so between two replies the row kept the previous
13759
+ // figure while the conversation underneath it grew by a whole step — tool results in particular can
13760
+ // be huge. The estimate takes over for the in-between frames (it never overwrites a reported figure
13761
+ // once one has arrived), so the bar tracks the request actually being built instead of lagging one
13762
+ // step behind it.
13763
+ if (!state.ctxReportedOnce) {
13764
+ state.ctxTokens = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
13765
+ state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || cfg.maxContextTokens || 1);
13766
+ }
12975
13767
  // Persist at every step boundary, not only at turn end. `messages` is the
12976
13768
  // SAME array this Agent appends to, so copying it here captures every
12977
13769
  // completed step: a crash mid-turn then loses at most the step in flight
@@ -12999,7 +13791,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12999
13791
  return;
13000
13792
  }
13001
13793
  if (e.type === 'compacted') {
13002
- state.ctxTokens = e.after || state.ctxTokens;
13794
+ if (!state.ctxReportedOnce) state.ctxTokens = e.after || state.ctxTokens;
13003
13795
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
13004
13796
  // Compaction is over, so the Working row goes back to a rotating phrase.
13005
13797
  //
@@ -13037,7 +13829,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13037
13829
  if (e.type === 'data') {
13038
13830
  // A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
13039
13831
  // because the paint path cannot tell "slow" from "quiet" — only the events can.
13040
- state.lastTokenAt = Date.now();
13832
+ state.lastTokenAt = Date.now();
13041
13833
  // Feed the answer text into the paced buffer instead of painting it. The
13042
13834
  // buffer closes any open reasoning region itself, in arrival order, so the
13043
13835
  // block settles exactly when the provider moved on rather than when the next
@@ -13068,21 +13860,27 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13068
13860
  // stay identical.
13069
13861
  //
13070
13862
 
13071
- // THE ESTIMATE ONLY. No provider number is mixed in anywhere.
13863
+ // THE ESTIMATE FILLS IN, THE REPORT LEADS.
13072
13864
  //
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;
13865
+ // `context` prices the request ABOUT to go out; `usage` reports the one that just did. Letting
13866
+ // the estimate overwrite a fresh report made the number alternate between two definitions.
13867
+ // THE CONTEXT EVENT OWNS THE VISIBLE NUMBER.
13868
+ //
13869
+ // It prices the request about to go out, which is the only thing "how full is my context"
13870
+ // can mean. A provider report describes the request that ALREADY went out, so showing it
13871
+ // here makes the gauge answer a different question and alternate between two formulas.
13872
+ //
13873
+ // THE ESTIMATE IS ONLY FOR A TURN THAT HAS NEVER HAD A REPORT.
13874
+ //
13875
+ // It cannot be reconciled with the report and should not try: measured against this project's
13876
+ // endpoint, a 4 000-character message is 530 tokens and the local rule calls it 953 — the
13877
+ // estimate is systematically ~1.8× high. Letting it take over whenever it was larger put the
13878
+ // inflated figure back on every event, so the gauge read 600k while streaming and 500k when
13879
+ // idle: two formulas, and the visible one was the wrong one.
13880
+ //
13881
+ // `ctxReportedOnce` latches when a real figure arrives. After that the estimate never wins.
13882
+ // The decision lives in `setCtxTokens` so every writer obeys the same rule.
13883
+ if (!state.ctxReportedOnce) state.ctxTokens = Number(e.tokens) || 0;
13086
13884
  viewCur.renderSoon(); return;
13087
13885
  }
13088
13886
  if (e.type === 'usage') {
@@ -13103,20 +13901,43 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13103
13901
  // - on a cache hit some providers report only the uncached remainder, which made the gauge
13104
13902
  // COLLAPSE mid-turn and then jump back.
13105
13903
  //
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
- }
13904
+ // PROVIDER REPORT ARRIVES -> the estimate it covers is done.
13905
+ //
13906
+ // The counter is `settled + estimate`: finished steps plus the one streaming now. When a usage
13907
+ // report lands, `settled` grows by that step's exact figure AND `estimate` still holds the
13908
+ // same step's guess, so the row showed roughly double — measured 25 estimated + 25 settled
13909
+ // reading 50 — and every later step built on the inflated total.
13910
+ //
13911
+ // Clearing `estimate` here is what makes the estimate a LIVE figure for the step in flight
13912
+ // rather than a running sum that never forgets.
13913
+ if (e.usage && Number.isFinite(e.usage.output)) {
13914
+ state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
13915
+ state.turnOutputEstimate = 0;
13916
+ state.turnOutputTokens = state.turnOutputSettled;
13917
+ }
13918
+ if (e.usage && Number.isFinite(e.usage.input) && e.usage.input > 0) {
13919
+ // `input` is the whole prompt; `cached` breaks out how much of it was a cache hit. Measured:
13920
+ // a 4000-character message reported prompt_tokens 530 and cached_tokens 0 — adding the two
13921
+ // would count the cached part twice.
13922
+ const reported = e.usage.input;
13923
+ // THE PROVIDER REPORTS THE CONTEXT SIZE OUTRIGHT.
13924
+ //
13925
+ // Measured against this project's own endpoint: `prompt_tokens` is 31 for a six-token
13926
+ // message plus the system prompt and tools, and `prompt_tokens_details.cached_tokens` is
13927
+ // reported separately — so `prompt_tokens` is ALREADY the whole prompt. Adding the cached
13928
+ // figure again, as an earlier version did, double-counted it.
13929
+ //
13930
+ // That makes the local estimate unnecessary whenever this arrives, and having both is what
13931
+ // produced a gauge alternating between 150k and 300k: the estimate and the report describe
13932
+ // different requests and overwrote each other. The report wins and the estimate stands down.
13933
+ // Routed through `setCtxTokens`, which latches the switch and mirrors onto the session so a
13934
+ // resume starts from the reported size. Duplicating those fields here is what let a later
13935
+ // estimate overwrite them.
13936
+ state.ctxTokens = reported;
13937
+ state.ctxReportedOnce = true;
13938
+ state.ctxSource = 'provider';
13939
+ if (session) { session.ctxTokens = reported; session.ctxReportedOnce = true; }
13940
+ }
13120
13941
  refreshCost(state, cfg);
13121
13942
  viewCur.renderSoon(); return;
13122
13943
  }
@@ -13132,22 +13953,23 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13132
13953
  sessHost.countOutputTokens(estimateTokensFloat(e.text));
13133
13954
  viewCur.renderSoon(); return;
13134
13955
  }
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.
13956
+ if (e.type === 'aborted' || e.type === 'stopped') {
13957
+ // A TURN CAN STOP WITHOUT THE REQUEST BEING ABORTED.
13958
+ //
13959
+ // `aborted` comes from the LLM stream; `stopped` comes from the agent loop after a tool
13960
+ // finished with `stopRequested` set — which is exactly what Esc does while a Bash command is
13961
+ // running. The TUI handled only `aborted`, so killing a long command left the `Using Bash` row
13962
+ // pulsing and printed no notice: the interrupt worked and looked like it had not.
13139
13963
  sessHost.flushStream();
13140
13964
  const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
13141
13965
  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.
13966
+ // Settle any RUNNING tool rows too. A tool interrupted mid-flight never gets its result, so
13967
+ // `pending` stayed true and the name kept pulsing forever.
13968
+ let interruptedTool = false;
13146
13969
  for (const m of state.chat) {
13147
- if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; }
13970
+ if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; interruptedTool = true; }
13148
13971
  }
13149
- // Use a special role that won't show the ✓ icon
13150
- state.chat.push({ role: 'aborted', text: C.red + 'interrupted' + C.reset });
13972
+ state.chat.push({ role: 'aborted', text: C.red + (interruptedTool ? 'interrupted (command stopped)' : 'interrupted') + C.reset });
13151
13973
  viewCur.render();
13152
13974
  return;
13153
13975
  }
@@ -13194,7 +14016,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13194
14016
  // Track the key argument as it streams so "Using Bash (cmd)" shows
13195
14017
  // the command DURING the run rather than only once it has finished.
13196
14018
  entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
13197
- entry._cache = null; // the argument is part of the rendered line
14019
+ entry._cache = null; // the argument is part of the rendered line
13198
14020
  if (entry.toolName === 'Write') {
13199
14021
  entry.streamContent = argStreamValue(entry._argStream, 'content');
13200
14022
  // Incremental line count so the preview's "… N more lines" note is
@@ -13554,9 +14376,11 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13554
14376
 
13555
14377
  // Same formula as everywhere else: messages PLUS the tool schemas.
13556
14378
  const approx = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
13557
- state.ctxTokens = approx;
14379
+ if (!state.ctxReportedOnce) state.ctxTokens = approx;
13558
14380
  state.ctxMax = cfg.maxContextTokens || state.ctxMax;
13559
- state.ctxPercent = usagePercent(approx, state.ctxMax);
14381
+ // Same rule as the resume path: once a provider has reported a size, the percentage comes from that
14382
+ // number, not from the estimate. Computing it from `approx` here made the row show `874k/1M` beside 73%.
14383
+ state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax);
13560
14384
  // `session.messages` now holds the full request, system prompt included, so the bar
13561
14385
  // reads the real text from here on and the turn-scoped copy must not be counted too.
13562
14386
  state.turnSystemPrompt = null;
@@ -13650,6 +14474,37 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13650
14474
  // Queue shrank: push the updated list to the web queue pane immediately.
13651
14475
  try { viewCur.pubStatusField('queued', [...state.queued]); } catch { /* best-effort */ }
13652
14476
  }
14477
+ // THE GOAL RESTARTS THE TURN.
14478
+ //
14479
+ // An agent with an objective that spans several turns stops when it runs out of things to say in THIS
14480
+ // turn, which looks exactly like finishing. The objective is what tells the two apart: while one is set and
14481
+ // not paused, the agent is sent back in with the goal quoted, so it continues rather than waiting to be
14482
+ // poked. `Goal clear` is the only thing that stops the loop, which is why the tool says never to clear a
14483
+ // goal that has not been met.
14484
+ //
14485
+ // Skipped in every case where the turn did not end normally: a dismissal means the user wants it to stop,
14486
+ // and a queued message is already the next turn (restarting here would run both).
14487
+ const goalNow = String(state.objective || '').trim();
14488
+ const goalApplies = goalNow && !state.goalPaused && !approvedPlan && !state.queued.length
14489
+ && !state._goalTurns; // set below, so one restart per goal per turn
14490
+ if (goalApplies) {
14491
+ state._goalTurns = (state._goalTurns || 0) + 1;
14492
+ viewCur.addChat({
14493
+ role: 'system',
14494
+ text: `[goal] continuing (${state._goalTurns}) — ${goalNow}`,
14495
+ });
14496
+ viewCur.render();
14497
+ // The prompt names the goal the agent itself wrote, and says what to do when it is done. Without the
14498
+ // second half the loop restarts forever: a model with no instruction to clear has no reason to.
14499
+ const continueText = [
14500
+ 'You have an active goal and the turn ended before it was reached.',
14501
+ `Goal: ${goalNow}`,
14502
+ '',
14503
+ 'Continue working on it. When the goal is fully met, call the Goal tool with action "clear" so the loop',
14504
+ 'stops; if it is still not met, keep going.',
14505
+ ].join('\n');
14506
+ void sessHost.submit(continueText);
14507
+ }
13653
14508
  // Approved plan: Plan mode OFF (write tools come back), then re-enter the agent
13654
14509
  // with the plan as a SYSTEM message. Deliberately NOT via submit(): that path
13655
14510
  // posts a user bubble and re-runs the whole submit flow, which is what produced
@@ -13710,40 +14565,53 @@ export async function startTUI(opts) {
13710
14565
  * Keeping one entry point is also what makes the number honest: there is exactly one definition of
13711
14566
  * "how much context is in use", so a future caller cannot invent a third.
13712
14567
  */
13713
- function setCtxTokens(tokens, max) {
14568
+ /**
14569
+ * The one place the context gauge is set.
14570
+ *
14571
+ * `opts.fromEstimate` marks a number the local tokenizer produced. Those are only used until the
14572
+ * endpoint reports a real size — measured against it, the estimate runs ~1.8x high (a 4 000-character
14573
+ * message is 530 tokens, the estimate says 953), so once a report exists the estimate is ignored and
14574
+ * the gauge stays on the reported figure instead of jumping between the two.
14575
+ *
14576
+ * `opts.reported` marks a provider figure; it latches the switch and mirrors onto the session so a
14577
+ * resume starts from the real size rather than the estimate.
14578
+ */
14579
+ function setCtxTokens(tokens, max, opts = {}) {
14580
+ if (opts.fromEstimate && state.ctxReportedOnce) return; // a report outranks every estimate
13714
14581
  const n = Math.max(0, Number(tokens) || 0);
13715
14582
  state.ctxTokens = n;
14583
+ if (opts.reported) {
14584
+ state.ctxReportedOnce = true;
14585
+ state.ctxSource = 'provider';
14586
+ if (session) { session.ctxTokens = n; session.ctxReportedOnce = true; }
14587
+ } else if (opts.fromEstimate) {
14588
+ state.ctxSource = 'estimate';
14589
+ }
13716
14590
  if (Number.isFinite(max) && max > 0) state.ctxMax = max;
13717
14591
  state.ctxPercent = usagePercent(n, state.ctxMax || 1);
13718
- // The trend /usage draws. Bounded, because a long session would otherwise grow it without limit.
13719
14592
  if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
13720
14593
  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);
14594
+ if (h[h.length - 1] !== n) {
14595
+ h.push(n);
14596
+ if (h.length > 240) h.splice(0, h.length - 240);
13729
14597
  }
13730
14598
  }
13731
14599
 
13732
14600
 
13733
- // Set when the config has no model, so the interface can say so without blocking startup.
13734
- let modelMissing = false;
13735
-
14601
+ // Set when the config has no model, so the interface can say so without blocking startup.
14602
+ let modelMissing = false;
14603
+
13736
14604
  // FIRST: Ensure model is initialized before restoring session
13737
14605
  if (!cfg.model || !cfg.innerModel) {
13738
14606
  // Model not set yet - use default or prompt user
13739
14607
  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;
14608
+ // NOT a return: a fresh install has no model yet, and the advice is `/model` — which needs the
14609
+ // prompt this function is about to build. Flagged instead, and reported once the TUI is up.
14610
+ modelMissing = true;
13743
14611
  }
13744
14612
  }
13745
14613
 
13746
- const state = makeState({ cfg, session, opts: { ...opts, modelMissing } });
14614
+ const state = makeState({ cfg, session, opts: { ...opts, modelMissing } });
13747
14615
  // Mirror the session on the state so `/move` can rebind ONE binding and have every
13748
14616
  // consumer follow (see `replaceSession`).
13749
14617
  state.session = session;
@@ -14259,7 +15127,7 @@ export async function startTUI(opts) {
14259
15127
  }
14260
15128
  let lastFrame = null;
14261
15129
  let paintScheduled = false;
14262
- let lastPaintAt = 0; // when the last frame actually went to the terminal
15130
+ let lastPaintAt = 0; // when the last frame actually went to the terminal
14263
15131
  // Deadline for the next FULL repaint. A differential painter compares against the frame
14264
15132
  // it BELIEVES it painted, never the real screen, so any divergence between the two is
14265
15133
  // permanent: it survives every later frame because the comparison keeps saying "that row
@@ -14288,13 +15156,49 @@ export async function startTUI(opts) {
14288
15156
  const forcePaint = state._forceRepaint === true;
14289
15157
  if (forcePaint) state._forceRepaint = false;
14290
15158
  const forceFull = forcePaint || (now - lastFullPaint) >= FULL_REPAINT_MS;
14291
- const out = diffFrame(forceFull ? null : lastFrame, frame);
15159
+ // ONLY A PAINT WITH NO PREVIOUS FRAME NEEDS THE BITMAPS AGAIN.
15160
+ //
15161
+ // `forceFull` fires once a second to repair drift, and it passes `null` as the previous frame. That is
15162
+ // not the same as a first paint: the terminal is still displaying every image. Re-sending one makes
15163
+ // Windows Terminal move the screen, and the next paint then draws the interface at the new offset —
15164
+ // which is what left the composer repeated down the page.
15165
+ const firstPaint = lastFrame === null;
15166
+ // THE CELL-GRID PAINTER.
15167
+ //
15168
+ // `forceFull` no longer means "throw away the previous frame". It means "the terminal may have drifted",
15169
+ // which is handled by re-sending bitmaps only: the text cells are compared every frame regardless, so a
15170
+ // full text repaint was never buying anything except the bitmap churn that shredded the screen.
15171
+ const resendImages = firstPaint || forcePaint;
15172
+ const out = paintCellFrame(frame, resendImages);
14292
15173
  if (forceFull) lastFullPaint = now;
14293
15174
  lastFrame = frame;
14294
15175
  state._hitboxes = frame.hitboxes || [];
14295
15176
  state._composerMeta = frame.composerMeta || [];
14296
- if (out) stdout.write(out);
14297
- lastPaintAt = now;
15177
+ if (out) {
15178
+ if (process.env.HNCODE_TRACE_PAINT) {
15179
+ try {
15180
+ const sixels = (out.match(/\x1bPq/g) || []).length;
15181
+ const rows = [...out.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
15182
+ const dir = path.join(process.env.USERPROFILE || process.env.HOME || '.', '.hncode');
15183
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
15184
+ fs.appendFileSync(path.join(dir, 'paint.log'), JSON.stringify({
15185
+ at: Date.now(),
15186
+ forceFull,
15187
+ bytes: out.length,
15188
+ sixels,
15189
+ rows: rows.length ? rows.join(',') : '',
15190
+ placements: [...out.matchAll(/\x1b\[\?80l\x1b\[(\d+);1H[\s\S]*?"1;1;(\d+);(\d+)/g)]
15191
+ .map((m) => ({ row: Number(m[1]), w: Number(m[2]), h: Number(m[3]) })),
15192
+ head: JSON.stringify(out.slice(0, 160)),
15193
+ tail: JSON.stringify(out.slice(-80)),
15194
+ newlines: (out.match(/\n/g) || []).length,
15195
+ height: dims().rows,
15196
+ }) + '\n');
15197
+ } catch { /* tracing must never break a paint */ }
15198
+ }
15199
+ stdout.write(out);
15200
+ }
15201
+ lastPaintAt = now;
14298
15202
  // "Painted" becomes observable here: everything that reaches the screen goes
14299
15203
  // through this one function, so the tick advancing means bytes left this process.
14300
15204
  // See src/flush-tick.js for what reads it.
@@ -14341,26 +15245,26 @@ export async function startTUI(opts) {
14341
15245
  */
14342
15246
  function countOutputTokens(n) { return sessHost.countOutputTokens(n); }
14343
15247
 
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
- }
15248
+ /**
15249
+ * The render rate this session is capped at, from `/settings` → Render FPS.
15250
+ *
15251
+ * Read on every call rather than cached: the setting can change mid-session, and a cached rate would
15252
+ * only take effect after a restart.
15253
+ */
15254
+ function renderFps() {
15255
+ const v = Number(cfg && cfg.render_fps);
15256
+ // A terminal cannot render at 0Hz; the lowest meaningful rate is one frame a second.
15257
+ return Number.isFinite(v) ? Math.max(1, Math.round(v)) : 30;
15258
+ }
15259
+
15260
+ function renderSoon() {
15261
+ if (paintScheduled) return;
15262
+ const budget = Math.max(1, Math.round(1000 / renderFps()));
15263
+ const wait = Math.max(0, (lastPaintAt || 0) + budget - Date.now());
15264
+ paintScheduled = true;
15265
+ if (wait === 0) { setImmediate(paintNow); return; }
15266
+ setTimeout(paintNow, wait);
15267
+ }
14364
15268
  function openPicker(p) {
14365
15269
  let footer = p.footer ? { focused: false, ...p.footer } : null;
14366
15270
  if (p.footerFor) {
@@ -14739,6 +15643,18 @@ export async function startTUI(opts) {
14739
15643
  // because `generateTitle` is a hoisted declaration in this same scope: it is defined far
14740
15644
  // below, but the binding exists as soon as this statement runs.
14741
15645
  sessHost.makeTitle = generateTitle;
15646
+ // The Goal tool writes the SAME objective field /goal does, so the status panel and the turn loop cannot
15647
+ // disagree about what the agent is working towards. `runTurn` reaches both through `sessHost`, which is why
15648
+ // the store is installed here rather than inside the tool.
15649
+ installGoalStore({
15650
+ get: () => state.objective || '',
15651
+ set: (goal) => {
15652
+ state.objective = String(goal || '');
15653
+ state.goalPaused = false;
15654
+ try { sessHost.persistState(); } catch { /* a failed save must not lose the goal for this turn */ }
15655
+ renderFrame();
15656
+ },
15657
+ });
14742
15658
  // The same indirection, for the same reason: `runTurn` drains the queue by re-entering
14743
15659
  // `submit`, which is a nested function of THIS scope. Referencing it by name from `runTurn`
14744
15660
  // threw `submit is not defined` and killed the process on the next turn — the identical
@@ -15470,7 +16386,7 @@ export async function startTUI(opts) {
15470
16386
  ],
15471
16387
  resolve,
15472
16388
  };
15473
- renderSoon();
16389
+ renderSoon();
15474
16390
  }),
15475
16391
  }))
15476
16392
  .then((res) => {
@@ -15768,10 +16684,21 @@ export async function startTUI(opts) {
15768
16684
  // advance identically — duplicated logic here would drift.
15769
16685
  function advanceQuestion(qs) {
15770
16686
  const total = qs.items.length;
16687
+ // REMEMBER WHAT WAS CHOSEN ON THIS QUESTION BEFORE LEAVING IT.
16688
+ //
16689
+ // `sel` and `picked` were global to the dialog, so moving on reset both to "first row, nothing picked" —
16690
+ // and coming back to re-read or change an earlier answer showed the first option as the current one, with
16691
+ // no sign of what had actually been chosen. A per-question record keeps the row and the selection where
16692
+ // the user left them.
16693
+ if (qs.index < total) {
16694
+ if (!qs.remember) qs.remember = new Map();
16695
+ qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
16696
+ }
15771
16697
  if (qs.index < total - 1) {
15772
16698
  qs.index++;
15773
- qs.sel = 0;
15774
- qs.picked = new Set();
16699
+ const back = qs.remember && qs.remember.get(qs.index);
16700
+ qs.sel = back ? back.sel : 0;
16701
+ qs.picked = back ? new Set(back.picked) : new Set();
15775
16702
  return;
15776
16703
  }
15777
16704
  qs.index = total; // the Other tab
@@ -16024,6 +16951,32 @@ export async function startTUI(opts) {
16024
16951
  // itself has no per-row action, so absorbing is the whole behaviour.
16025
16952
  return true;
16026
16953
  }
16954
+ case 'questionTab': {
16955
+ // Clicking a tab label switches to that question. `hb.index` is the tab's position; the trailing one is
16956
+ // the Other tab, whose field opens on arrival, exactly as `switchTab` does it. Without this case the
16957
+ // labels were plain text and the only way back to an earlier question was the keyboard.
16958
+ if (!state.question) return false;
16959
+ const qs = state.question;
16960
+ if (qs.editing === 'other') qs.otherText = qs.editingText || '';
16961
+ else if (qs.editing === 'supplement') qs.supplement = qs.editingText || '';
16962
+ qs.editing = null;
16963
+ const totalTabs = qs.items.length;
16964
+ if (qs.index < totalTabs) {
16965
+ if (!qs.remember) qs.remember = new Map();
16966
+ qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
16967
+ }
16968
+ qs.index = Math.max(0, Math.min(totalTabs, hb.index));
16969
+ const backTab = qs.index < totalTabs && qs.remember ? qs.remember.get(qs.index) : null;
16970
+ qs.sel = backTab ? backTab.sel : 0;
16971
+ qs.picked = backTab ? new Set(backTab.picked) : new Set();
16972
+ if (qs.index === totalTabs) {
16973
+ qs.editing = 'supplement';
16974
+ qs.editingText = qs.supplement || '';
16975
+ qs.editingCaret = qs.editingText.length;
16976
+ }
16977
+ renderFrame();
16978
+ return true;
16979
+ }
16027
16980
  case 'questionItem': {
16028
16981
  // Click an option in the AskUserQuestion dialog. `hb.index` is the position
16029
16982
  // in [options..., Other] for the CURRENT question tab. Without this case the
@@ -16454,7 +17407,19 @@ export async function startTUI(opts) {
16454
17407
  // now 7-12ms warm, so the notice did nothing except paint a frame, get replaced a moment later, and
16455
17408
  // make the paste feel like it was waiting for something. The text appears, or an error is reported —
16456
17409
  // both are visible without an intermediate state.
16457
- const { text, via } = await readClipboardContentAsync();
17410
+ const read = await readClipboardContentAsync();
17411
+ let { text, via } = read;
17412
+ // A COPIED IMAGE WITH AN ADDRESS DOES NOT NEED A FILE.
17413
+ //
17414
+ // Chromium puts the pixels, an HTML fragment (`<img src=…>`) and — for a plain image — the image's own
17415
+ // URL on the clipboard at once. The reader answers with the file list or the saved PNG first, so a copied
17416
+ // web image was written to disk for content the model can simply fetch. The TEXT formats are the only ones
17417
+ // that carry an address, so they are consulted before the PNG is accepted.
17418
+ if (via === 'image') {
17419
+ const direct = await readClipboardTextOnly();
17420
+ const url = direct ? imageUrlFromClipboardText(direct) : null;
17421
+ if (url) { text = url; via = 'text'; }
17422
+ }
16458
17423
  if (text) {
16459
17424
  // An image collapses to a `[paste #N image]` chip, the same way a multi-line
16460
17425
  // paste becomes `[paste #N +L lines]`, so the prompt does not fill up with a
@@ -16489,8 +17454,14 @@ export async function startTUI(opts) {
16489
17454
  renderFrame();
16490
17455
  return;
16491
17456
  }
17457
+ // QUEUED ITEMS ARE ALREADY EXPANDED; THE DRAFT IS NOT.
17458
+ //
17459
+ // `submit` expands paste placeholders before it queues a message, so `state.queued` holds real text. The
17460
+ // draft still holds `[Pasted text #1 …]` markers — those are what the composer shows — and steering it
17461
+ // verbatim sent the marker to the model and printed it in the transcript. Same expansion the composer does.
16492
17462
  const items = (state.queued || []).slice();
16493
- const draft = String(state.input || '').trim();
17463
+ const rawDraft = String(state.input || '');
17464
+ const draft = rawDraft.trim() ? expandPastes(rawDraft, state.pastes).trim() : '';
16494
17465
  if (draft) items.push(draft);
16495
17466
  if (!items.length) {
16496
17467
  notice('Nothing queued to steer', 'error');
@@ -16613,7 +17584,58 @@ export async function startTUI(opts) {
16613
17584
  return keybindings;
16614
17585
  }
16615
17586
 
17587
+ // WHICH KEYS ACCELERATE WHEN HELD. Anything that MOVES — the caret, the selection, the cursor — and
17588
+ // anything that DELETES one unit at a time. Enter, Escape, Tab and ordinary characters are deliberately
17589
+ // absent: firing those twice is a bug, not a convenience.
17590
+ const REPEATABLE_KEYS = new Set([
17591
+ 'backspace', 'delete', 'left', 'right', 'up', 'down',
17592
+ 'home', 'end', 'pageup', 'pagedown', 'c-left', 'c-right', 'c-up', 'c-down',
17593
+ ]);
17594
+ /** A gap longer than this starts a fresh run, so two separate presses are never merged. */
17595
+ const REPEAT_WINDOW_MS = 140; // the same deliberate/burst boundary `wheelStep` uses
17596
+ /** The most one event may stand for. */
17597
+ const REPEAT_MAX = 8; // same ceiling as `wheelStep`
17598
+ const repeatState = { key: null, at: 0, run: 0 };
17599
+
16616
17600
  function handleKey(t) {
17601
+ // A KITTY GRAPHICS REPLY IS NOT A KEYSTROKE. It is the terminal acknowledging an upload (`G i=31;OK`) or
17602
+ // reporting that it dropped an image. The token exists so the bytes are CONSUMED rather than typed into the
17603
+ // composer; there is nothing to act on yet, because a failed upload is handled by placing the formula as
17604
+ // text next time rather than by retrying mid-frame.
17605
+ if (t && t.kittyReply !== undefined) return;
17606
+ // HOLDING A KEY REPEATS IT AT A FIXED RATE, WHICHEVER ROW IT LANDS ON.
17607
+ //
17608
+ // The wheel already accelerates: `wheelStep` widens the jump as notches arrive faster, so a fast scroll
17609
+ // moves eight rows where a deliberate one moves one. Every OTHER repeat — backspace, the arrows, home and
17610
+ // end, page keys — ran once per event no matter how fast the terminal auto-repeated, so holding backspace
17611
+ // through a long line crawled while holding the wheel flew. Both are "the same key, arriving fast".
17612
+ //
17613
+ // The run is folded into ONE call carrying `_repeat`, and the handlers that support it act that many
17614
+ // times. A key not in the list is untouched: Enter, Escape and ordinary characters must never fire twice.
17615
+ if (t && typeof t.key === 'string' && REPEATABLE_KEYS.has(t.key)) {
17616
+ const now = Date.now();
17617
+ const gap = now - (repeatState.at || 0);
17618
+ repeatState.at = now;
17619
+ repeatState.key = t.key;
17620
+ // THE BATCH COMES FROM THE GAP, NOT FROM HOW LONG THE KEY HAS BEEN HELD.
17621
+ //
17622
+ // Counting the run and batching on its length multiplied twice: a held key already arrives every ~40ms,
17623
+ // so growing the batch to eight made the last event stand for eight MORE deltas on top of the ~25Hz
17624
+ // stream — hundreds of characters a second, and the caret shot past where anyone wanted it. The gap
17625
+ // alone says how fast the terminal repeats, which is the thing to compensate for.
17626
+ //
17627
+ // 140ms+ a deliberate tap -> 1
17628
+ // 70-140 a brisk double tap -> 2
17629
+ // 35-70 the terminal repeating -> 3
17630
+ // under 35 a fast repeat -> 4
17631
+ t._repeat = gap >= REPEAT_WINDOW_MS ? 1
17632
+ : gap >= REPEAT_WINDOW_MS / 2 ? 2
17633
+ : gap >= REPEAT_WINDOW_MS / 4 ? 3
17634
+ : 4;
17635
+ } else {
17636
+ repeatState.key = null;
17637
+ repeatState.at = 0;
17638
+ }
16617
17639
  // USER KEYBINDINGS, their own layer. A binding either REPLACES a key with another key
16618
17640
  // (applied by rewriting `t.key` below, so the whole switch sees the bound key without
16619
17641
  // knowing bindings exist) or runs a slash command. Mouse and wheel events are never
@@ -16710,7 +17732,7 @@ export async function startTUI(opts) {
16710
17732
  return;
16711
17733
  }
16712
17734
  if (t.key === 'pageup' || t.key === 'pagedown') {
16713
- scrollChat(state, t.key === 'pageup' ? 10 : -10);
17735
+ scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
16714
17736
  renderFrame();
16715
17737
  return;
16716
17738
  }
@@ -16812,10 +17834,18 @@ export async function startTUI(opts) {
16812
17834
  if (qs.editing === 'other') qs.otherText = qs.editingText || '';
16813
17835
  else if (qs.editing === 'supplement') qs.supplement = qs.editingText || '';
16814
17836
  qs.editing = null;
17837
+ // Tab is the OTHER way to leave a question, so it has to remember too — otherwise switching back with
17838
+ // Shift+Tab showed the first option again even though the previous answer was still recorded.
17839
+ const total0 = qs.items.length;
17840
+ if (qs.index < total0) {
17841
+ if (!qs.remember) qs.remember = new Map();
17842
+ qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
17843
+ }
16815
17844
  const n = total + 1; // questions + the Other tab
16816
17845
  qs.index = (qs.index + dir + n) % n;
16817
- qs.sel = 0;
16818
- qs.picked = new Set();
17846
+ const back = qs.index < total0 && qs.remember ? qs.remember.get(qs.index) : null;
17847
+ qs.sel = back ? back.sel : 0;
17848
+ qs.picked = back ? new Set(back.picked) : new Set();
16819
17849
  if (qs.index === total) {
16820
17850
  // Landing on the Other tab opens its text field directly.
16821
17851
  qs.editing = 'supplement';
@@ -16865,7 +17895,18 @@ export async function startTUI(opts) {
16865
17895
  return;
16866
17896
  }
16867
17897
  if (!answer) { renderFrame(); return; } // nothing typed: stay
16868
- qs.answers[cur.question] = answer;
17898
+ // A MULTI-SELECT KEEPS ITS OTHER PICKS. This overwrote `answers[cur.question]` with the typed text
17899
+ // alone, so a question where "Option A" and "Other: something" were both chosen came back with only
17900
+ // the typed half — the checkboxes were on screen but the answer that reached the model had lost them.
17901
+ if (cur.multiSelect) {
17902
+ const labels = [...qs.picked].sort((a, b) => a - b)
17903
+ .map((i) => (cur.options[i] ? cur.options[i].label : null))
17904
+ .filter(Boolean);
17905
+ labels.push(answer);
17906
+ qs.answers[cur.question] = labels.join(', ');
17907
+ } else {
17908
+ qs.answers[cur.question] = answer;
17909
+ }
16869
17910
  qs.otherText = '';
16870
17911
  qs.editing = null;
16871
17912
  advance();
@@ -16915,7 +17956,13 @@ export async function startTUI(opts) {
16915
17956
  if (t.key === 'enter') {
16916
17957
  if (qs.sel >= otherIdx) { openEditor('other'); return; }
16917
17958
  if (cur.multiSelect) {
16918
- const labels = [...qs.picked].sort((a, b) => a - b).map((i) => cur.options[i].label);
17959
+ // An option index past `cur.options` is the trailing "Other" row, whose text lives in `otherText`.
17960
+ // Dereferencing it blindly threw on a question where Other was among the checked rows.
17961
+ const labels = [...qs.picked].sort((a, b) => a - b)
17962
+ .map((i) => (cur.options[i] ? cur.options[i].label : null))
17963
+ .filter(Boolean);
17964
+ const typed = (qs.otherText || '').trim();
17965
+ if (typed) labels.push(typed);
16919
17966
  if (labels.length) qs.answers[cur.question] = labels.join(', ');
16920
17967
  advance();
16921
17968
  return;
@@ -16933,7 +17980,7 @@ export async function startTUI(opts) {
16933
17980
  return;
16934
17981
  }
16935
17982
  if (t.key === 'pageup' || t.key === 'pagedown') {
16936
- scrollChat(state, t.key === 'pageup' ? 10 : -10);
17983
+ scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
16937
17984
  renderFrame();
16938
17985
  return;
16939
17986
  }
@@ -16950,7 +17997,7 @@ export async function startTUI(opts) {
16950
17997
  // its own arrow behaviour on the un-modified keys.
16951
17998
  if (t.key === 'c-up' || t.key === 'c-down') {
16952
17999
  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"));
18000
+ scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
16954
18001
  renderFrame();
16955
18002
  return;
16956
18003
  }
@@ -16959,21 +18006,21 @@ export async function startTUI(opts) {
16959
18006
  // Ctrl+L — expand / collapse the plugin log block(s). The last one is the
16960
18007
  // useful target (it is the most recent), but every pluginLog message is toggled
16961
18008
  // 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
- }
18009
+ if (t.key === 'alt-r') {
18010
+ // Only when nothing owns the keyboard: a picker or a dialog has its own meaning for R.
18011
+ if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
18012
+ const current = (cfg && cfg.provider) || '';
18013
+ if (!current) {
18014
+ notice('No provider selected — use /model to pick one.', 'error');
18015
+ } else {
18016
+ // Fire and forget: the notice reports the outcome, and blocking the key handler on a network
18017
+ // round-trip would freeze input for as long as the endpoint takes.
18018
+ void fetchAndRegisterModels(state, cfg, current, { notice: (m, k) => notice(m, k) });
18019
+ }
18020
+ renderFrame();
18021
+ return;
18022
+ }
18023
+ }
16977
18024
  if (t.key === 'c-l') {
16978
18025
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16979
18026
  const logs = (state.chat || []).filter((m) => m && m.pluginLog);
@@ -17212,7 +18259,7 @@ export async function startTUI(opts) {
17212
18259
  return;
17213
18260
  }
17214
18261
  if (t.key === 'pageup' || t.key === 'pagedown') {
17215
- scrollChat(state, t.key === 'pageup' ? 10 : -10);
18262
+ scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
17216
18263
  renderFrame(); return;
17217
18264
  }
17218
18265
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
@@ -17220,25 +18267,36 @@ export async function startTUI(opts) {
17220
18267
  renderFrame(); return;
17221
18268
  }
17222
18269
  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
18270
  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] || '');
18271
+ const hist = Array.isArray(state.history) ? state.history : [];
18272
+ const single = cur.indexOf('\n') === -1;
18273
+ if (hist.length) {
18274
+ // ↑ ENTERS HISTORY from anywhere in a ONE-LINE draft, and from the very start of a
18275
+ // multi-line one, where the caret has somewhere else to go.
18276
+ if (t.key === 'up' && state.historyIdx === -1 && (single || (state.caret || 0) === 0)) {
18277
+ // Keep what is in the composer so ↓ can bring it back.
18278
+ state.draftInput = cur;
18279
+ state.historyIdx = hist.length - 1;
18280
+ state.input = hist[state.historyIdx] || '';
17238
18281
  state.caret = state.input.length;
17239
18282
  refreshMenu(state); renderFrame();
18283
+ return;
18284
+ }
18285
+ if (state.historyIdx >= 0) {
18286
+ if (t.key === 'up') {
18287
+ if (state.historyIdx === 0) return; // oldest entry: the caret keys below take over
18288
+ state.historyIdx -= 1;
18289
+ state.input = hist[state.historyIdx] || '';
18290
+ } else {
18291
+ const next = state.historyIdx + 1;
18292
+ // Past the newest entry the DRAFT returns, not an empty line.
18293
+ state.input = next >= hist.length ? (state.draftInput || '') : (hist[next] || '');
18294
+ state.historyIdx = next >= hist.length ? -1 : next;
18295
+ }
18296
+ state.caret = state.input.length;
18297
+ refreshMenu(state); renderFrame();
18298
+ return;
17240
18299
  }
17241
- return;
17242
18300
  }
17243
18301
  const insideW = Math.max(0, dims().cols - 2);
17244
18302
  const layout = composerLayout(state, insideW - 3);
@@ -17254,7 +18312,11 @@ export async function startTUI(opts) {
17254
18312
  }
17255
18313
  // Draft is on its last/first row: the key is free. Scroll the transcript
17256
18314
  // (useful while output streams and you want to read back).
17257
- scrollChat(state, t.key === 'up' ? 3 : -3);
18315
+ //
18316
+ // `_repeat` makes a HELD arrow accelerate like the wheel does: three rows for a tap, up to
18317
+ // `REPEAT_MAX * 3` when the key is repeating. Without it the rows crawled while the wheel flew, even
18318
+ // though both arrive as the same fast stream of events.
18319
+ scrollChat(state, (t.key === 'up' ? 3 : -3) * Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1)));
17258
18320
  renderFrame(); return;
17259
18321
  }
17260
18322
  }
@@ -17279,16 +18341,33 @@ export async function startTUI(opts) {
17279
18341
  return;
17280
18342
  }
17281
18343
  // First/last row (or an empty composer): this is history's turn.
18344
+ // This mirrors the streaming branch above exactly — the two used to differ, and the difference
18345
+ // was the bug: here a lone ↓ could re-enter the walk at the newest entry instead of resting at
18346
+ // the draft, so ↓ alternated between the last history entry and an empty box forever.
17282
18347
  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] || '');
18348
+ if (t.key === 'up' && state.historyIdx === -1) {
18349
+ // Entering history: keep the composer so ↓ can bring it back.
18350
+ state.draftInput = cur;
18351
+ state.historyIdx = state.history.length - 1;
18352
+ state.input = state.history[state.historyIdx] || '';
18353
+ } else if (state.historyIdx >= 0) {
18354
+ if (t.key === 'up') {
18355
+ if (state.historyIdx === 0) return; // oldest entry: the caret keys take over
18356
+ state.historyIdx -= 1;
18357
+ state.input = state.history[state.historyIdx] || '';
18358
+ } else {
18359
+ const next = state.historyIdx + 1;
18360
+ // Past the newest entry the DRAFT returns, and the index goes to -1 so the next ↓ rests.
18361
+ state.input = next >= state.history.length ? (state.draftInput || '') : (state.history[next] || '');
18362
+ state.historyIdx = next >= state.history.length ? -1 : next;
18363
+ }
18364
+ } else {
18365
+ return; // not browsing and not ↑: the key is not ours
18366
+ }
17288
18367
  state.caret = state.input.length;
17289
18368
  refreshMenu(state); renderFrame();
18369
+ return;
17290
18370
  }
17291
- return;
17292
18371
  }
17293
18372
  }
17294
18373
  }
@@ -17383,10 +18462,12 @@ export async function startTUI(opts) {
17383
18462
  if (state.panel) {
17384
18463
  const p = state.panel;
17385
18464
  if (t.key === 'escape') { state.panel = null; state.hoverHit = null; renderFrame(); return; }
17386
- if (t.key === 'up') { p.top = Math.max(0, (p.top || 0) - 1); renderFrame(); return; }
17387
- if (t.key === 'down') { p.top = (p.top || 0) + 1; renderFrame(); return; }
17388
- if (t.key === 'pageup') { p.top = Math.max(0, (p.top || 0) - 10); renderFrame(); return; }
17389
- if (t.key === 'pagedown') { p.top = (p.top || 0) + 10; renderFrame(); return; }
18465
+ // A held key moves the panel by `_repeat` rows, the same acceleration the transcript scroll uses.
18466
+ const rep = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
18467
+ if (t.key === 'up') { p.top = Math.max(0, (p.top || 0) - rep); renderFrame(); return; }
18468
+ if (t.key === 'down') { p.top = (p.top || 0) + rep; renderFrame(); return; }
18469
+ if (t.key === 'pageup') { p.top = Math.max(0, (p.top || 0) - 10 * rep); renderFrame(); return; }
18470
+ if (t.key === 'pagedown') { p.top = (p.top || 0) + 10 * rep; renderFrame(); return; }
17390
18471
  if (t.key === 'home') { p.top = 0; renderFrame(); return; }
17391
18472
  return;
17392
18473
  }
@@ -17592,7 +18673,7 @@ export async function startTUI(opts) {
17592
18673
  // ↑/↓ scroll the box's history, in ROWS. Held on the state as an offset from
17593
18674
  // the tail, matching the transcript's convention.
17594
18675
  if (t.key === 'up' || t.key === 'down') {
17595
- const step = t.key === 'up' ? 4 : -4;
18676
+ const step = (t.key === 'up' ? 4 : -4) * Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
17596
18677
  state.btwScroll = Math.max(0, (state.btwScroll || 0) + step);
17597
18678
  renderFrame();
17598
18679
  return;
@@ -17650,14 +18731,23 @@ export async function startTUI(opts) {
17650
18731
  if (t.key === 'up' || t.key === 'down') {
17651
18732
  const cur = state.input || '';
17652
18733
  if (cur === '') {
17653
- // Empty composer: history owns the key. With NO history the key does
17654
- // nothing — it must not silently scroll the transcript.
18734
+ // Empty composer: history owns the key. With no history it does nothing, rather than silently
18735
+ // scrolling the transcript.
17655
18736
  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] || '');
18737
+ if (state.historyIdx === -1) {
18738
+ if (t.key === 'down') return; // already at the draft: nothing older to reach
18739
+ state.draftInput = cur;
18740
+ state.historyIdx = state.history.length - 1;
18741
+ state.input = state.history[state.historyIdx] || '';
18742
+ } else if (t.key === 'up') {
18743
+ if (state.historyIdx === 0) return; // oldest entry
18744
+ state.historyIdx -= 1;
18745
+ state.input = state.history[state.historyIdx] || '';
18746
+ } else {
18747
+ const next = state.historyIdx + 1;
18748
+ state.input = next >= state.history.length ? (state.draftInput || '') : (state.history[next] || '');
18749
+ state.historyIdx = next >= state.history.length ? -1 : next;
18750
+ }
17661
18751
  state.caret = state.input.length;
17662
18752
  state.composerSel = null;
17663
18753
  refreshMenu(state); renderFrame();
@@ -17685,11 +18775,20 @@ export async function startTUI(opts) {
17685
18775
  // for the key, so it becomes HISTORY. (Scrolling the transcript is still
17686
18776
  // available on PgUp/PgDn and the wheel — the arrows belong to the input.)
17687
18777
  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] || '');
18778
+ if (state.historyIdx === -1) {
18779
+ if (t.key === 'down') return; // already at the draft
18780
+ state.draftInput = cur;
18781
+ state.historyIdx = state.history.length - 1;
18782
+ state.input = state.history[state.historyIdx] || '';
18783
+ } else if (t.key === 'up') {
18784
+ if (state.historyIdx === 0) return; // oldest entry
18785
+ state.historyIdx -= 1;
18786
+ state.input = state.history[state.historyIdx] || '';
18787
+ } else {
18788
+ const next = state.historyIdx + 1;
18789
+ state.input = next >= state.history.length ? (state.draftInput || '') : (state.history[next] || '');
18790
+ state.historyIdx = next >= state.history.length ? -1 : next;
18791
+ }
17693
18792
  state.caret = state.input.length;
17694
18793
  state.composerSel = null;
17695
18794
  refreshMenu(state); renderFrame(); return;
@@ -17697,13 +18796,13 @@ export async function startTUI(opts) {
17697
18796
  return;
17698
18797
  }
17699
18798
  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;
18799
+ // HOW HARD THE USER IS TURNING, from the interval between key events.
18800
+ //
18801
+ // One rate cannot serve both gestures: a fixed one row makes a fast spin crawl, a fixed three jumps
18802
+ // past what a careful reader wanted. The interval between events is the speed, and it is already
18803
+ // in the stream — no timer, no separate gesture detection. Slow: one row. Fast: up to eight.
18804
+ const step = wheelStep(state, "wheel");
18805
+ const d = t.key === 'wheelup' ? step : -step;
17707
18806
  if (state.panel) {
17708
18807
  const p = state.panel;
17709
18808
  p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
@@ -17754,7 +18853,7 @@ export async function startTUI(opts) {
17754
18853
  }
17755
18854
  return;
17756
18855
  }
17757
- scrollChat(state, t.key === 'pageup' ? 10 : -10);
18856
+ scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
17758
18857
  renderFrame(); return;
17759
18858
  }
17760
18859
  // Shift+arrows EXTEND a composer selection instead of moving the caret. The
@@ -17799,13 +18898,24 @@ export async function startTUI(opts) {
17799
18898
 
17800
18899
  if (t.key === 'left' || t.key === 'right') {
17801
18900
  const dir = t.key === 'left' ? -1 : 1;
17802
- const mk = adjacentPasteMarker(state.input, state.caret || 0, dir);
17803
- if (mk) {
17804
- state.caret = dir < 0 ? mk.start : mk.end;
17805
- } else if (dir < 0) {
17806
- state.caret = Math.max(0, (state.caret || 0) - 1);
17807
- } else {
17808
- state.caret = Math.min(state.input.length, (state.caret || 0) + 1);
18901
+ // A held arrow moves `_repeat` cells per event; a paste marker is skipped whole, so it consumes one
18902
+ // step of the run rather than being walked into character by character.
18903
+ const times = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
18904
+ for (let nth = 0; nth < times; nth++) {
18905
+ const mk = adjacentPasteMarker(state.input, state.caret || 0, dir);
18906
+ if (mk) {
18907
+ state.caret = dir < 0 ? mk.start : mk.end;
18908
+ } else if (dir < 0) {
18909
+ const next = Math.max(0, (state.caret || 0) - 1);
18910
+ const moved = next !== (state.caret || 0);
18911
+ state.caret = next;
18912
+ if (!moved) break;
18913
+ } else {
18914
+ const next = Math.min(state.input.length, (state.caret || 0) + 1);
18915
+ const moved = next !== (state.caret || 0);
18916
+ state.caret = next;
18917
+ if (!moved) break;
18918
+ }
17809
18919
  }
17810
18920
  // A horizontal move abandons the sticky column (kimi's setCursorCol).
17811
18921
  state._caretPrefCol = null;
@@ -17892,21 +19002,29 @@ export async function startTUI(opts) {
17892
19002
  renderFrame();
17893
19003
  return;
17894
19004
  }
17895
- const sel = state.composerSel;
17896
- if (sel && sel.anchor !== sel.head) {
17897
- const a = Math.min(sel.anchor, sel.head);
17898
- const h = Math.max(sel.anchor, sel.head);
17899
- state.input = state.input.slice(0, a) + state.input.slice(h);
17900
- state.caret = a;
17901
- } else if (state.caret > 0) {
17902
- const mk = adjacentPasteMarker(state.input, state.caret, -1);
17903
- if (mk) {
17904
- state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
17905
- state.caret = mk.start;
17906
- state.pastes.delete(mk.id);
19005
+ // `_repeat` is how many times the held key stands for — see the note in `handleKey`. A held backspace
19006
+ // then erases `_repeat` characters per event instead of one, which is what makes it keep up with the
19007
+ // wheel's acceleration.
19008
+ const times = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
19009
+ for (let nth = 0; nth < times; nth++) {
19010
+ const sel = state.composerSel;
19011
+ if (sel && sel.anchor !== sel.head) {
19012
+ const a = Math.min(sel.anchor, sel.head);
19013
+ const h = Math.max(sel.anchor, sel.head);
19014
+ state.input = state.input.slice(0, a) + state.input.slice(h);
19015
+ state.caret = a;
19016
+ } else if (state.caret > 0) {
19017
+ const mk = adjacentPasteMarker(state.input, state.caret, -1);
19018
+ if (mk) {
19019
+ state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
19020
+ state.caret = mk.start;
19021
+ state.pastes.delete(mk.id);
19022
+ } else {
19023
+ state.input = state.input.slice(0, state.caret - 1) + state.input.slice(state.caret);
19024
+ state.caret--;
19025
+ }
17907
19026
  } else {
17908
- state.input = state.input.slice(0, state.caret - 1) + state.input.slice(state.caret);
17909
- state.caret--;
19027
+ break; // nothing left to delete; the remaining repeats would be no-ops
17910
19028
  }
17911
19029
  }
17912
19030
  // Deleting can also change the mention (or delete the `@` itself), so the
@@ -17916,19 +19034,25 @@ export async function startTUI(opts) {
17916
19034
  renderFrame(); state.composerSel = null; return;
17917
19035
  }
17918
19036
  if (t.key === 'delete') {
17919
- const sel = state.composerSel;
17920
- if (sel && sel.anchor !== sel.head) {
17921
- const a = Math.min(sel.anchor, sel.head);
17922
- const h = Math.max(sel.anchor, sel.head);
17923
- state.input = state.input.slice(0, a) + state.input.slice(h);
17924
- state.caret = a;
17925
- } else {
17926
- const mk = adjacentPasteMarker(state.input, state.caret, 1);
17927
- if (mk) {
17928
- state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
17929
- state.pastes.delete(mk.id);
17930
- } else if (state.caret < state.input.length) {
17931
- state.input = state.input.slice(0, state.caret) + state.input.slice(state.caret + 1);
19037
+ // `_repeat` batches a held key, the same way backspace does.
19038
+ const times = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
19039
+ for (let nth = 0; nth < times; nth++) {
19040
+ const sel = state.composerSel;
19041
+ if (sel && sel.anchor !== sel.head) {
19042
+ const a = Math.min(sel.anchor, sel.head);
19043
+ const h = Math.max(sel.anchor, sel.head);
19044
+ state.input = state.input.slice(0, a) + state.input.slice(h);
19045
+ state.caret = a;
19046
+ } else {
19047
+ const mk = adjacentPasteMarker(state.input, state.caret, 1);
19048
+ if (mk) {
19049
+ state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
19050
+ state.pastes.delete(mk.id);
19051
+ } else if (state.caret < state.input.length) {
19052
+ state.input = state.input.slice(0, state.caret) + state.input.slice(state.caret + 1);
19053
+ } else {
19054
+ break; // at the end of the line: the remaining repeats would be no-ops
19055
+ }
17932
19056
  }
17933
19057
  }
17934
19058
  refreshMenu(state); renderFrame(); state.composerSel = null; return;
@@ -18067,26 +19191,26 @@ export async function startTUI(opts) {
18067
19191
  // Read live, so a /external flip applies to the very next tool call.
18068
19192
  function externalAllowed() { return sessHost.externalAllowed(); }
18069
19193
 
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
-
19194
+ /**
19195
+ * How many rows one notch or keypress should move, from how fast they are arriving.
19196
+ *
19197
+ * Shared by the wheel and by Ctrl+Up/Down so the two cannot drift: they are the same gesture with
19198
+ * different hardware. The EVENT STREAM is the speed sensor — a wheel turned slowly sends notches far
19199
+ * apart and one spun sends them in a burst, and a key held down repeats at the OS repeat rate.
19200
+ *
19201
+ * `family` keeps the two clocks apart: alternating a wheel notch with a Ctrl+Up should not be read as one
19202
+ * burst of either. It also makes the FIRST event of a family one row — `Infinity` lands in the slowest
19203
+ * step, so nothing jumps on the first touch after a pause.
19204
+ */
19205
+ function wheelStep(state, family) {
19206
+ const now = Date.now();
19207
+ const at = state._stepAt || (state._stepAt = {});
19208
+ const since = at[family] ? now - at[family] : Infinity;
19209
+ at[family] = now;
19210
+ // 140ms is about the gap between deliberate notches; under 55ms they are arriving in a burst.
19211
+ return since > 140 ? 1 : since > 90 ? 2 : since > 55 ? 4 : 8;
19212
+ }
19213
+
18090
19214
  function scrollChat(state, delta) {
18091
19215
  const cols = dims().cols, rows = dims().rows;
18092
19216
  // Only the TOTAL row count is needed to clamp the scroll position. This used to
@@ -18119,7 +19243,12 @@ function wheelStep(state, family) {
18119
19243
  state.menuOpen = false; state.menuList = []; state.menuSel = 0;
18120
19244
  state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
18121
19245
  state.mentionOffset = 0;
19246
+ // A NEW TURN means a new chance for the goal to restart it. The counter exists so one turn restarts the
19247
+ // goal at most once; without resetting it here, the second turn of a goal would never continue.
19248
+ state._goalTurns = 0;
18122
19249
  state.historyIdx = -1;
19250
+ // The draft belongs to the history walk that just ended; the next ↑ starts fresh.
19251
+ state.draftInput = '';
18123
19252
  if (!text) { renderFrame(); return; }
18124
19253
  // `@name message` sends STRAIGHT to a teammate, without the model.
18125
19254
  //
@@ -18346,42 +19475,42 @@ function wheelStep(state, family) {
18346
19475
  // means two requests and a round trip to learn an id that this side invents anyway. Passing
18347
19476
  // `new: true` lets the daemon accept a prompt against any session URL and have it run in a new
18348
19477
  // 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
- };
19478
+ hub.actions.submit = async (text, opts) => {
19479
+ if (opts && opts.new) {
19480
+ // Flush the outgoing session first: its todos and transcript belong to the id being replaced,
19481
+ // and `dispatch` does exactly what `/new` does, so the two cannot drift apart.
19482
+ await core.dispatch('new', '');
19483
+ // The hub's rows and event ring belong to the session that just ended. Clearing them keeps a
19484
+ // viewer from showing the previous conversation under the new id.
19485
+ hub.rows = [];
19486
+ hub.events = [];
19487
+ }
19488
+ // `core.submit` AWAITS THE WHOLE TURN, so its result is the finished turn — no polling needed.
19489
+ const out = await core.submit(text);
19490
+ // `opts.return` asks for the transcript of what just ran:
19491
+ //
19492
+ // "turn" the rows of this turn, tool results included
19493
+ // "full" every row the session holds
19494
+ // "none" (default) whatever `core.submit` returned, unchanged
19495
+ //
19496
+ // Without this a caller had to submit, wait an unknown time, then issue a second request for
19497
+ // `/api/state` — two round trips and a guess at how long the turn would take.
19498
+ const want = opts && opts.return;
19499
+ if (!want || want === 'none') return out;
19500
+ // FORCE ONE SYNC: `syncChat` does nothing while no browser is subscribed, so `hub.rows` would be
19501
+ // empty here and the caller would get an empty transcript from a turn that plainly ran.
19502
+ try { hub.syncChat(state.chat || []); } catch { /* the reply still carries `result` */ }
19503
+ // The turn begins at the last user row; everything after it is what the prompt produced.
19504
+ const rows = Array.isArray(hub.rows) ? hub.rows : [];
19505
+ let from = 0;
19506
+ for (let k = rows.length - 1; k >= 0; k--) { if (rows[k] && rows[k].role === 'user') { from = k; break; } }
19507
+ const slice = want === 'full' ? rows : rows.slice(from);
19508
+ const detail = (opts && opts.detail) || 'full';
19509
+ const shaped = detail === 'minimal' ? slice.filter((r) => r.role !== 'tool')
19510
+ : detail === 'summary' ? slice.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r))
19511
+ : slice;
19512
+ return { result: out, rows: shaped, detail, sessionId: session && session.id };
19513
+ };
18385
19514
  hub.actions.dispatch = async (cmd, arg) => {
18386
19515
  const out = await core.dispatch(cmd, arg);
18387
19516
  // The core runs the command; the repaint is this front end's business, so it stays
@@ -19390,14 +20519,18 @@ function quit() {
19390
20519
  process.exit(1);
19391
20520
  });
19392
20521
  process.on('unhandledRejection', (reason) => {
20522
+ // REPORT AND CARRY ON. A rejected background task (a failed update check, a refused model refresh,
20523
+ // a clipboard read) must not end the session: those callers are fire-and-forget by design, and the
20524
+ // turn loop reports its own failures as events. Exiting here turned any one of them into a lost
20525
+ // conversation.
19393
20526
  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);
20527
+ const msg = (reason && reason.message) || String(reason);
20528
+ const stack = (reason && reason.stack) || '';
20529
+ // The log is the only place the trace survives: stderr writes into the alternate screen, which the
20530
+ // exit handler wipes.
20531
+ noteExit(`unhandledRejection: ${msg}\n${stack}`);
20532
+ // Visible in the transcript rather than on a screen that is about to be cleared.
20533
+ try { notice(`background task failed: ${msg}`, 'error'); } catch {}
19401
20534
  });
19402
20535
 
19403
20536
  // REMOTE CONTROL (--control): expose a local socket so a script can queue a