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