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