@hncode/hncode 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -38
- package/src/config.js +10 -3
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/tools/edit.js +88 -161
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/read.js +11 -1
- package/src/tools/web-search.js +139 -88
- package/src/tui.js +330 -142
- package/src/web.js +47 -1
package/src/tui.js
CHANGED
|
@@ -427,6 +427,7 @@ export const TIPS = [
|
|
|
427
427
|
'Queued messages wait for the turn to finish',
|
|
428
428
|
'/steer injects a message into the running turn',
|
|
429
429
|
'Ctrl+R reloads the current session from disk',
|
|
430
|
+
'Alt+R re-fetches the provider\'s model list',
|
|
430
431
|
'/help shows available commands at any time',
|
|
431
432
|
'/undo rolls back the transcript AND the files',
|
|
432
433
|
'/fork copies the session so you can branch',
|
|
@@ -551,8 +552,8 @@ export function reducedMotionOn(state) {
|
|
|
551
552
|
* tool, a thinking preview) gets the same treatment from one place.
|
|
552
553
|
*/
|
|
553
554
|
export function spinnerFrame(state, tick) {
|
|
554
|
-
if (reducedMotionOn(state)) {
|
|
555
|
-
const t = tick !== undefined ? tick : (state && state.spin) || 0;
|
|
555
|
+
if (reducedMotionOn(state)) {
|
|
556
|
+
const t = tick !== undefined ? tick : (state && state.spin) || 0;
|
|
556
557
|
return { glyph: REDUCED_MOTION_DOT, dim: Math.floor(t / (REDUCED_MOTION_CYCLE_MS / 80)) % 2 === 1 };
|
|
557
558
|
}
|
|
558
559
|
const n = SPINNER.length;
|
|
@@ -646,7 +647,7 @@ export function sweepPulse(dist, half, edge = 'cosine', flat = 0) {
|
|
|
646
647
|
* glint is a point travelling along the row, not a wash sweeping over it. It stops at one rather than
|
|
647
648
|
* shrinking to zero because a two-character row still needs something to see.
|
|
648
649
|
*/
|
|
649
|
-
export const LIGHT_FRACTION = 1 / 4;
|
|
650
|
+
export const LIGHT_FRACTION = 1 / 4;
|
|
650
651
|
|
|
651
652
|
/**
|
|
652
653
|
* Milliseconds per unit of `state.spin`.
|
|
@@ -678,7 +679,7 @@ export function lightWidth(n) {
|
|
|
678
679
|
* palettes: the peak moves one letter at a time, and the row reads as a lamp travelling rather than as a
|
|
679
680
|
* single pixel flashing.
|
|
680
681
|
*/
|
|
681
|
-
export const TAIL_CHARS =
|
|
682
|
+
export const TAIL_CHARS = 4;
|
|
682
683
|
|
|
683
684
|
|
|
684
685
|
/**
|
|
@@ -693,7 +694,7 @@ export const TAIL_CHARS = 2;
|
|
|
693
694
|
*/
|
|
694
695
|
function totalHalf(n) {
|
|
695
696
|
const want = lightWidth(n) + 2 * TAIL_CHARS;
|
|
696
|
-
const capped = Math.min(want, Math.max(2, n - 1));
|
|
697
|
+
const capped = Math.min(want, Math.max(2, n - 1));
|
|
697
698
|
return capped / 2;
|
|
698
699
|
}
|
|
699
700
|
|
|
@@ -712,19 +713,44 @@ function coreHalf(n) {
|
|
|
712
713
|
*
|
|
713
714
|
* At 30fps a frame is 33ms, which is what makes the crossing in `CROSS_MS` six frames long.
|
|
714
715
|
*/
|
|
715
|
-
export const ANIM_FPS = 30;
|
|
716
|
+
export const ANIM_FPS = 30;
|
|
716
717
|
export const FRAME_MS = 1000 / ANIM_FPS;
|
|
717
718
|
|
|
718
719
|
/**
|
|
719
720
|
* How long one step of the light lasts, in milliseconds.
|
|
720
721
|
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
722
|
+
* FIXED at 80ms — fast enough that a sweep reads as motion, slow enough that the eye is not chasing it.
|
|
723
|
+
*
|
|
724
|
+
* It followed the output rate for a while. That was tried and dropped: a model writing slowly got a
|
|
725
|
+
* sweep so slow it looked stuck, a fast one got a blur, and the same row changed pace between turns for
|
|
726
|
+
* no reason the reader could see. One rate is calmer and predictable, which is what an ambient
|
|
727
|
+
* indicator needs to be.
|
|
725
728
|
*/
|
|
726
|
-
export const STEP_MS =
|
|
727
|
-
|
|
729
|
+
export const STEP_MS = 65;
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The step for a TOOL name, slower than the Working row's.
|
|
733
|
+
*
|
|
734
|
+
* A tool name is short — `Read` is four characters — so at the Working row's rate the light crosses it in
|
|
735
|
+
* 560ms and the row is dark for the rest of the cycle. On screen that reads as a single flash, not as
|
|
736
|
+
* movement. 150ms per character gives a four-character name a 1.4s round: slow enough to follow, still
|
|
737
|
+
* obviously running.
|
|
738
|
+
*/
|
|
739
|
+
const TOOL_STEP_MS = 150;
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* How long the light stays on one character, from how fast tokens are arriving.
|
|
743
|
+
*
|
|
744
|
+
* `tps` is output tokens per second for the step in flight. A fast stream should look fast — the row
|
|
745
|
+
* keeping a leisurely 50ms step while tokens pour in reads as the animation lagging behind the text it
|
|
746
|
+
* is supposed to accompany.
|
|
747
|
+
*
|
|
748
|
+
* The clamp is the important half. Under about 10 tok/s the model is thinking more than writing and the
|
|
749
|
+
* sweep slows down so it does not look frantic over a trickle; above about 80 tok/s it is a wall of text
|
|
750
|
+
* and the light moves quickly but never strobes. 28ms is just under a 33ms render frame, so a step is
|
|
751
|
+
* always painted at least once; 200ms is slow enough to read as deliberate without looking stuck.
|
|
752
|
+
*/
|
|
753
|
+
|
|
728
754
|
|
|
729
755
|
|
|
730
756
|
|
|
@@ -739,16 +765,22 @@ export const STEP_MS = 50;
|
|
|
739
765
|
* ended; `lightMargin` takes the larger of the two.
|
|
740
766
|
*/
|
|
741
767
|
const ENTRY_EXIT_CHARS = 10;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
* the
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
768
|
+
/**
|
|
769
|
+
* The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger —
|
|
770
|
+
* but CAPPED so the light reaches the text in reasonable time.
|
|
771
|
+
*
|
|
772
|
+
* Ten cells either side is what makes the timing match Claude Code on a long row. On a SHORT row it is
|
|
773
|
+
* most of the cycle: `Edit` is four characters, so the travel is 24 cells and the light spends the first
|
|
774
|
+
* 500ms still outside the text, showing nothing. A row that looks frozen for half a second and then
|
|
775
|
+
* blinks once reads as broken, which is exactly how the Using row was reported.
|
|
776
|
+
*
|
|
777
|
+
* The cap is `n + 4` per side at most, so a short row spends a few steps entering rather than ten, and a
|
|
778
|
+
* long row is unchanged.
|
|
779
|
+
*/
|
|
780
|
+
function lightMargin(n) {
|
|
781
|
+
const half = totalHalf(Math.max(1, n));
|
|
782
|
+
return Math.max(1, Math.min(ENTRY_EXIT_CHARS, Math.max(half, Math.ceil(Math.max(1, n) / 4))));
|
|
783
|
+
}
|
|
752
784
|
|
|
753
785
|
/**
|
|
754
786
|
* The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
|
|
@@ -756,9 +788,9 @@ function lightMargin(n) {
|
|
|
756
788
|
* `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only `count * stepMs`
|
|
757
789
|
* of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
|
|
758
790
|
*/
|
|
759
|
-
export function crossMs(count) {
|
|
791
|
+
export function crossMs(count, stepMs = STEP_MS) {
|
|
760
792
|
const cells = Math.max(1, count) + 2 * lightMargin(count);
|
|
761
|
-
return cells *
|
|
793
|
+
return cells * stepMs;
|
|
762
794
|
}
|
|
763
795
|
|
|
764
796
|
/**
|
|
@@ -771,7 +803,7 @@ export function crossMs(count) {
|
|
|
771
803
|
* Claude Code can use zero because its entry run is ten cells: the light is already off the row for a
|
|
772
804
|
* second before it comes back. Ours enters from three cells away, too short to cover the wrap.
|
|
773
805
|
*/
|
|
774
|
-
export const REST_MS =
|
|
806
|
+
export const REST_MS = 300;
|
|
775
807
|
|
|
776
808
|
/**
|
|
777
809
|
* The full cycle for a row: one crossing, then the rest.
|
|
@@ -779,8 +811,8 @@ export const REST_MS = 250;
|
|
|
779
811
|
* `count` enters into it: a longer row has more characters to light, so its cycle is longer. It used to be
|
|
780
812
|
* constant, because a fixed-time crossing spread itself over however many cells there happened to be.
|
|
781
813
|
*/
|
|
782
|
-
export function cycleMs(count) {
|
|
783
|
-
return crossMs(count) + REST_MS;
|
|
814
|
+
export function cycleMs(count, stepMs = STEP_MS) {
|
|
815
|
+
return crossMs(count, stepMs) + REST_MS;
|
|
784
816
|
}
|
|
785
817
|
|
|
786
818
|
/**
|
|
@@ -861,7 +893,7 @@ function lightOrigin(key, anchor, now) {
|
|
|
861
893
|
* 1.9 seconds for `Working...`. Taking milliseconds decouples the two: the caller may redraw as often
|
|
862
894
|
* as it likes, and the light's speed is stated directly rather than implied by a frame count.
|
|
863
895
|
*/
|
|
864
|
-
export function lightAt(phaseMs, index, count, edge = 'cosine') {
|
|
896
|
+
export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
|
|
865
897
|
const n = Math.max(1, count);
|
|
866
898
|
// Two widths, and the distinction is the shape of the light: `half` is the OUTER edge (core plus the
|
|
867
899
|
// fade either side) and `core` is the part that stays at full brightness.
|
|
@@ -886,9 +918,9 @@ export function lightAt(phaseMs, index, count, edge = 'cosine') {
|
|
|
886
918
|
// The margin is `max(10, half)`: ten is the target, but the light must clear its own width or it would
|
|
887
919
|
// still be lighting the last characters when the round ended — the failure the half-width version was
|
|
888
920
|
// written to fix, and which a flat ten reintroduces on a wide band.
|
|
889
|
-
const margin =
|
|
890
|
-
const travel = n + 2 * margin;
|
|
891
|
-
const steps = Math.floor((phaseMs / crossMs(n)) * travel);
|
|
921
|
+
const margin = lightMargin(n);
|
|
922
|
+
const travel = n + 2 * margin;
|
|
923
|
+
const steps = Math.floor((phaseMs / crossMs(n, stepMs)) * travel);
|
|
892
924
|
const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
|
|
893
925
|
// `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
|
|
894
926
|
// where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
|
|
@@ -916,13 +948,13 @@ export function lightAt(phaseMs, index, count, edge = 'cosine') {
|
|
|
916
948
|
* @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
|
|
917
949
|
* @returns {string} ANSI, ready to splice into a row
|
|
918
950
|
*/
|
|
919
|
-
function shimmerText(text, phaseMs, base, glint, edge = 'cosine') {
|
|
951
|
+
function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
|
|
920
952
|
const s = String(text == null ? '' : text);
|
|
921
953
|
const n = s.length;
|
|
922
954
|
if (!n) return '';
|
|
923
955
|
const out = [];
|
|
924
956
|
for (let i = 0; i < n; i++) {
|
|
925
|
-
const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge,
|
|
957
|
+
const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
|
|
926
958
|
const r = Math.round(base[0] + (glint[0] - base[0]) * t);
|
|
927
959
|
const g = Math.round(base[1] + (glint[1] - base[1]) * t);
|
|
928
960
|
const b = Math.round(base[2] + (glint[2] - base[2]) * t);
|
|
@@ -1531,40 +1563,58 @@ export function lineDiff(oldStr, newStr, startLine = 1) {
|
|
|
1531
1563
|
|
|
1532
1564
|
// Word-wrap a string that may contain ANSI colour codes, measuring VISIBLE
|
|
1533
1565
|
// width only. Escape sequences are carried over to the line they belong to.
|
|
1534
|
-
function wrapAnsiWords(str, width) {
|
|
1535
|
-
width = Math.max(1, width | 0);
|
|
1536
|
-
const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
|
|
1537
|
-
const out = [];
|
|
1538
|
-
let line = '';
|
|
1539
|
-
let lineW = 0;
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1566
|
+
function wrapAnsiWords(str, width) {
|
|
1567
|
+
width = Math.max(1, width | 0);
|
|
1568
|
+
const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
|
|
1569
|
+
const out = [];
|
|
1570
|
+
let line = '';
|
|
1571
|
+
let lineW = 0;
|
|
1572
|
+
// THE COLOUR ACTIVE AT THE BREAK, carried onto the next row.
|
|
1573
|
+
//
|
|
1574
|
+
// A colour is opened once and closed at the end of the string, so a line wrapped mid-colour leaves the
|
|
1575
|
+
// continuation starting with plain text — the terminal paints it in its DEFAULT foreground, which on a
|
|
1576
|
+
// dark theme is brighter than the grey the rest of the block uses. That is what a wrapped, expanded
|
|
1577
|
+
// command looked like: the first row grey and every row below it lighter.
|
|
1578
|
+
//
|
|
1579
|
+
// Tracked from the escapes seen so far and re-emitted at the start of each new row, then cleared. `0m`
|
|
1580
|
+
// resets it, so a line that closes its colour before the break does not reopen one.
|
|
1581
|
+
let active = '';
|
|
1582
|
+
const scan = (text) => {
|
|
1583
|
+
for (const m of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
|
|
1584
|
+
active = m[1] === '0' || m[1] === '' ? '' : `\x1b[${m[1]}m`;
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
const push = () => { if (line !== '') out.push(line); line = active; lineW = 0; };
|
|
1588
|
+
for (const tok of tokens) {
|
|
1589
|
+
if (tok === '') continue;
|
|
1590
|
+
const tw = visualCol(tok);
|
|
1591
|
+
if (/^\s+$/.test(tok)) {
|
|
1592
|
+
if (lineW > 0) { line += tok; lineW += tw; }
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if (lineW > 0 && lineW + tw > width) push();
|
|
1596
|
+
scan(tok);
|
|
1597
|
+
if (tw > width && visualCol(tok) > width) {
|
|
1598
|
+
// Hard-split an over-long token by visible columns.
|
|
1599
|
+
let chunk = '';
|
|
1600
|
+
let cw = 0;
|
|
1601
|
+
for (const ch of tok) {
|
|
1602
|
+
const chw = visualCol(ch);
|
|
1603
|
+
if (cw + chw > width) { out.push(chunk); chunk = active; cw = 0; }
|
|
1604
|
+
chunk += ch; cw += chw;
|
|
1605
|
+
}
|
|
1606
|
+
if (chunk) { line = chunk; lineW = cw; }
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
line += tok;
|
|
1610
|
+
lineW += tw;
|
|
1611
|
+
}
|
|
1612
|
+
if (line !== '') out.push(line);
|
|
1613
|
+
if (out.length === 0) out.push('');
|
|
1614
|
+
// Rows that were seeded with a carried colour but ended up holding nothing are dropped: they would
|
|
1615
|
+
// render as an invisible escape-only line.
|
|
1616
|
+
return out.map((s) => s.replace(/^\x1b\[[0-9;]*m$/, '')).filter((s) => s !== '' || out.length === 1);
|
|
1617
|
+
}
|
|
1568
1618
|
|
|
1569
1619
|
// Word-aware wrap that also returns each row's starting offset in the source
|
|
1570
1620
|
// string, so the caret can be mapped to a (row, col) inside the composer.
|
|
@@ -2075,7 +2125,7 @@ function keyArgument(name, args, workspace, expanded = false) {
|
|
|
2075
2125
|
}
|
|
2076
2126
|
return '';
|
|
2077
2127
|
}
|
|
2078
|
-
export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false) {
|
|
2128
|
+
export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false) {
|
|
2079
2129
|
const name = msg.toolName || '';
|
|
2080
2130
|
const arg = keyArgument(name, msg.toolArgs, workspace, expanded);
|
|
2081
2131
|
const verb = msg.pending ? 'Using' : 'Used';
|
|
@@ -2119,16 +2169,21 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
|
|
|
2119
2169
|
// `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time to
|
|
2120
2170
|
// zero so the light never moved at all.
|
|
2121
2171
|
const anchorSpin = pulseStart ?? spin;
|
|
2122
|
-
// THE KEY
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2126
|
-
//
|
|
2127
|
-
// key
|
|
2128
|
-
|
|
2172
|
+
// THE KEY IS THE ROW'S OWN IDENTITY, and nothing else.
|
|
2173
|
+
//
|
|
2174
|
+
// It used to carry `anchorSpin` as well, on the reasoning that the same message re-animated on a new
|
|
2175
|
+
// tick should get a new origin. The opposite happened: `anchorSpin` is `pulseStart ?? spin`, and when
|
|
2176
|
+
// the caller passes no `pulseStart` it IS the tick counter — it changes every 80ms, so every frame
|
|
2177
|
+
// built a new key, `lightOrigin` created a fresh origin for it, and `elapsedMs` stayed at 0. The light
|
|
2178
|
+
// sat still, except for the one frame after the row content changed and the message object was
|
|
2179
|
+
// replaced.
|
|
2180
|
+
//
|
|
2181
|
+
// A row animates from the moment it appears until it stops being pending, and its id is stable across
|
|
2182
|
+
// that whole life: one origin, and elapsed grows from there.
|
|
2183
|
+
const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}` : `tool:${name}`;
|
|
2129
2184
|
const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
|
|
2130
2185
|
// ONE shimmer for both rows — see `shimmerText`.
|
|
2131
|
-
const phaseMs = elapsedMs % cycleMs(name.length);
|
|
2186
|
+
const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
|
|
2132
2187
|
|
|
2133
2188
|
// The same restraint as the Working row's glint: the light is the base colour with a small lift in
|
|
2134
2189
|
// the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
|
|
@@ -2136,7 +2191,12 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
|
|
|
2136
2191
|
const BASE = [0, 215, 255]; // cyan: the name's resting colour
|
|
2137
2192
|
const GLINT = [110, 240, 255]; // the same hue, a shade brighter
|
|
2138
2193
|
|
|
2139
|
-
|
|
2194
|
+
// The same setting the Working row honours: a pending tool's name is decoration too.
|
|
2195
|
+
if (reducedMotion) {
|
|
2196
|
+
nameDisplay = col(name, C.cyan);
|
|
2197
|
+
} else {
|
|
2198
|
+
nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS);
|
|
2199
|
+
}
|
|
2140
2200
|
} else {
|
|
2141
2201
|
// Non-pending: show in plain cyan
|
|
2142
2202
|
nameDisplay = col(name, C.cyan);
|
|
@@ -2384,7 +2444,7 @@ export function todoPanelHeight(state) {
|
|
|
2384
2444
|
// wrapping, no ANSI interpretation. It exists to answer "what did the model
|
|
2385
2445
|
// actually send" when a rendering bug makes a reply look wrong — with the renderer
|
|
2386
2446
|
// in the way there is no way to tell a bad reply from a bad render.
|
|
2387
|
-
function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode, shimmerEdge) {
|
|
2447
|
+
function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode, shimmerEdge, reducedMotion = false) {
|
|
2388
2448
|
if (rawMode && (msg.role === 'assistant' || msg.role === 'thinking')) {
|
|
2389
2449
|
const src = String(msg.text == null ? '' : msg.text);
|
|
2390
2450
|
// escape the ANSI bytes so a stray `\x1b[31m` in the model's output cannot
|
|
@@ -2397,7 +2457,7 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
2397
2457
|
// Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
|
|
2398
2458
|
// The body already carries its own ANSI colours, so mark it pre-colored.
|
|
2399
2459
|
if (msg.role === 'tool') {
|
|
2400
|
-
|
|
2460
|
+
const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion);
|
|
2401
2461
|
// Red bullet for a failed call (a Bash non-zero exit / [error: …]).
|
|
2402
2462
|
const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
|
|
2403
2463
|
const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
|
|
@@ -3596,12 +3656,12 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3596
3656
|
const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
|
|
3597
3657
|
const rowWidth = border ? boxText : innerW;
|
|
3598
3658
|
const cached = m._cache;
|
|
3599
|
-
// Which rows animate? A pending tool call and a running compaction block
|
|
3600
|
-
//
|
|
3601
|
-
//
|
|
3602
|
-
//
|
|
3603
|
-
const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
|
|
3604
|
-
const spinKeyNow = animating ? (
|
|
3659
|
+
// Which rows animate? A pending tool call and a running compaction block do, and their appearance
|
|
3660
|
+
// depends on the current step of the light. Everything else gets a constant key, so a static row is
|
|
3661
|
+
// laid out once and reused across frames.
|
|
3662
|
+
// A pending tool advances from the wall clock at TOOL_STEP_MS, so its cache key is THAT step index.
|
|
3663
|
+
const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
|
|
3664
|
+
const spinKeyNow = animating ? Math.floor(Date.now() / TOOL_STEP_MS) : 0;
|
|
3605
3665
|
const valid = cached
|
|
3606
3666
|
&& cached.rowW === rowWidth && cached.expanded === expanded
|
|
3607
3667
|
&& cached.textRef === m.text && cached.streamRef === m.streamContent
|
|
@@ -3655,7 +3715,8 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3655
3715
|
// happen (replacing a word). Those are caught by the `_cache` nulling at the
|
|
3656
3716
|
// edit sites, which is what the shared-store path below relies on too.
|
|
3657
3717
|
const countLen = typeof m.text === 'string' ? m.text.length : 0;
|
|
3658
|
-
|
|
3718
|
+
// Only a NON-ANIMATING message can be answered from its row count. See the note below.
|
|
3719
|
+
if (!valid && !cached && !animating && m._count && m._count.key === countKey && m._count.textLen === countLen) {
|
|
3659
3720
|
msgLen[mi] = m._count.len + rules;
|
|
3660
3721
|
cursor += msgLen[mi];
|
|
3661
3722
|
prevBordered = border;
|
|
@@ -3685,7 +3746,8 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3685
3746
|
msgLen[mi] = shared.rows.length + rules;
|
|
3686
3747
|
} else {
|
|
3687
3748
|
const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
|
|
3688
|
-
state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge)
|
|
3749
|
+
state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
|
|
3750
|
+
reducedMotionOn(state));
|
|
3689
3751
|
const wrapped = rows.map(rowToLine);
|
|
3690
3752
|
const isPending2 = m.pending ? 1 : 0;
|
|
3691
3753
|
const cc = {
|
|
@@ -3907,6 +3969,10 @@ export function metricsSignature(state, n, w, expanded, innerW) {
|
|
|
3907
3969
|
mix(m.pluginLog && m.expanded ? 1 : 0);
|
|
3908
3970
|
mix(m._cache ? 1 : 0);
|
|
3909
3971
|
mix(m._count ? 1 : 0);
|
|
3972
|
+
// A pending row repaints as the light moves, with none of the fields above changing. Without this
|
|
3973
|
+
// the signature matched every frame and the transcript was never re-walked, so the animation froze
|
|
3974
|
+
// until the message itself was touched.
|
|
3975
|
+
if (m.pending) mix(Math.floor(Date.now() / STEP_MS));
|
|
3910
3976
|
}
|
|
3911
3977
|
return h;
|
|
3912
3978
|
}
|
|
@@ -11964,24 +12030,45 @@ function registerProvider(state, cfg, h, name, baseUrl, apiKey, protocol, typeLa
|
|
|
11964
12030
|
fetchAndRegisterModels(state, cfg, name, h);
|
|
11965
12031
|
}
|
|
11966
12032
|
|
|
11967
|
-
async function fetchAndRegisterModels(state, cfg, providerName, h) {
|
|
11968
|
-
const p = (cfg.raw.providers || {})[providerName] || {};
|
|
11969
|
-
const baseUrl = p.base_url || p.baseUrl || '';
|
|
11970
|
-
if (!baseUrl) return;
|
|
11971
|
-
h.notice(`Discovering models for ${providerName}…`, 'info');
|
|
11972
|
-
const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
|
|
11973
|
-
|
|
11974
|
-
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
11979
|
-
|
|
11980
|
-
|
|
11981
|
-
|
|
11982
|
-
|
|
11983
|
-
|
|
11984
|
-
|
|
12033
|
+
async function fetchAndRegisterModels(state, cfg, providerName, h) {
|
|
12034
|
+
const p = (cfg.raw.providers || {})[providerName] || {};
|
|
12035
|
+
const baseUrl = p.base_url || p.baseUrl || '';
|
|
12036
|
+
if (!baseUrl) return;
|
|
12037
|
+
h.notice(`Discovering models for ${providerName}…`, 'info');
|
|
12038
|
+
const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
|
|
12039
|
+
// THE FETCH MUST SUCCEED BEFORE ANYTHING IS REMOVED.
|
|
12040
|
+
//
|
|
12041
|
+
// Deleting first and fetching second means an unreachable endpoint leaves the provider with no models at
|
|
12042
|
+
// all. Everything below runs only after a non-empty result.
|
|
12043
|
+
if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
|
|
12044
|
+
|
|
12045
|
+
cfg.raw.models = cfg.raw.models || {};
|
|
12046
|
+
// Snapshot what this provider had, so the merge below can keep fields the source does not report.
|
|
12047
|
+
const prevByKey = new Map();
|
|
12048
|
+
for (const key of Object.keys(cfg.raw.models)) {
|
|
12049
|
+
if (key.split('/')[0] !== providerName) continue;
|
|
12050
|
+
prevByKey.set(key, cfg.raw.models[key]);
|
|
12051
|
+
delete cfg.raw.models[key];
|
|
12052
|
+
}
|
|
12053
|
+
|
|
12054
|
+
// REPLACE THE LIST WITH WHAT WAS JUST FETCHED.
|
|
12055
|
+
//
|
|
12056
|
+
// A model the endpoint no longer reports disappears, and a renamed one does not appear twice. Merging
|
|
12057
|
+
// over the old entries kept both for ever, which is what made Alt+R look like it did nothing.
|
|
12058
|
+
const kept = [];
|
|
12059
|
+
for (const m of models) {
|
|
12060
|
+
const key = modelKey(providerName, m.id);
|
|
12061
|
+
// Merge over the SNAPSHOT: the live entries were just deleted, so reading them yields undefined and
|
|
12062
|
+
// the merge folds nothing — every field the source omits (context_length, pricing) would be wiped.
|
|
12063
|
+
const entry = mergeRefreshedModelEntry(providerName, m, prevByKey.get(key));
|
|
12064
|
+
cfg.raw.models[key] = entry;
|
|
12065
|
+
kept.push({ ...entry, id: m.id });
|
|
12066
|
+
}
|
|
12067
|
+
// Rewrite the provider's TOML blocks from that list. `addModel` appends one block per model and returns
|
|
12068
|
+
// early when one already exists, so calling it here left the removed entries on disk.
|
|
12069
|
+
try { replaceProviderModels(providerName, kept); } catch { /* the in-memory list is still correct */ }
|
|
12070
|
+
h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
|
|
12071
|
+
}
|
|
11985
12072
|
|
|
11986
12073
|
// Merge ONE refreshed model's data over its previous config entry.
|
|
11987
12074
|
//
|
|
@@ -12298,10 +12385,17 @@ export function tokenize(str) {
|
|
|
12298
12385
|
if (f === undefined) break;
|
|
12299
12386
|
out.push({ key: f === 'P' ? 'f1' : 'escape' });
|
|
12300
12387
|
i += 3;
|
|
12301
|
-
} else {
|
|
12302
|
-
|
|
12303
|
-
i
|
|
12304
|
-
|
|
12388
|
+
} else {
|
|
12389
|
+
// `ESC <printable>` is Alt+that key. A second ESC stays a plain Escape.
|
|
12390
|
+
const altNext = str[i + 1];
|
|
12391
|
+
if (altNext && altNext >= '!' && altNext <= '~') {
|
|
12392
|
+
out.push({ key: 'alt-' + altNext.toLowerCase() });
|
|
12393
|
+
i += 2;
|
|
12394
|
+
continue;
|
|
12395
|
+
}
|
|
12396
|
+
out.push({ key: 'escape' });
|
|
12397
|
+
i++;
|
|
12398
|
+
}
|
|
12305
12399
|
} else if (c === '\r') {
|
|
12306
12400
|
out.push({ key: 'enter' });
|
|
12307
12401
|
i++;
|
|
@@ -12941,7 +13035,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12941
13035
|
if (e.type === 'data') {
|
|
12942
13036
|
// A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
|
|
12943
13037
|
// because the paint path cannot tell "slow" from "quiet" — only the events can.
|
|
12944
|
-
state.lastTokenAt = Date.now();
|
|
13038
|
+
state.lastTokenAt = Date.now();
|
|
12945
13039
|
// Feed the answer text into the paced buffer instead of painting it. The
|
|
12946
13040
|
// buffer closes any open reasoning region itself, in arrival order, so the
|
|
12947
13041
|
// block settles exactly when the provider moved on rather than when the next
|
|
@@ -13007,21 +13101,20 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
13007
13101
|
// - on a cache hit some providers report only the uncached remainder, which made the gauge
|
|
13008
13102
|
// COLLAPSE mid-turn and then jump back.
|
|
13009
13103
|
//
|
|
13010
|
-
//
|
|
13011
|
-
//
|
|
13012
|
-
//
|
|
13013
|
-
//
|
|
13014
|
-
//
|
|
13015
|
-
//
|
|
13016
|
-
//
|
|
13017
|
-
//
|
|
13018
|
-
|
|
13019
|
-
|
|
13020
|
-
state.
|
|
13021
|
-
|
|
13022
|
-
|
|
13023
|
-
|
|
13024
|
-
}
|
|
13104
|
+
// PROVIDER REPORT ARRIVES -> the estimate it covers is done.
|
|
13105
|
+
//
|
|
13106
|
+
// The counter is `settled + estimate`: finished steps plus the one streaming now. When a usage
|
|
13107
|
+
// report lands, `settled` grows by that step's exact figure AND `estimate` still holds the
|
|
13108
|
+
// same step's guess, so the row showed roughly double — measured 25 estimated + 25 settled
|
|
13109
|
+
// reading 50 — and every later step built on the inflated total.
|
|
13110
|
+
//
|
|
13111
|
+
// Clearing `estimate` here is what makes the estimate a LIVE figure for the step in flight
|
|
13112
|
+
// rather than a running sum that never forgets.
|
|
13113
|
+
if (e.usage && Number.isFinite(e.usage.output)) {
|
|
13114
|
+
state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
|
|
13115
|
+
state.turnOutputEstimate = 0;
|
|
13116
|
+
state.turnOutputTokens = state.turnOutputSettled;
|
|
13117
|
+
}
|
|
13025
13118
|
refreshCost(state, cfg);
|
|
13026
13119
|
viewCur.renderSoon(); return;
|
|
13027
13120
|
}
|
|
@@ -13099,6 +13192,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
13099
13192
|
// Track the key argument as it streams so "Using Bash (cmd)" shows
|
|
13100
13193
|
// the command DURING the run rather than only once it has finished.
|
|
13101
13194
|
entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
|
|
13195
|
+
entry._cache = null; // the argument is part of the rendered line
|
|
13102
13196
|
if (entry.toolName === 'Write') {
|
|
13103
13197
|
entry.streamContent = argStreamValue(entry._argStream, 'content');
|
|
13104
13198
|
// Incremental line count so the preview's "… N more lines" note is
|
|
@@ -14159,6 +14253,7 @@ export async function startTUI(opts) {
|
|
|
14159
14253
|
}
|
|
14160
14254
|
let lastFrame = null;
|
|
14161
14255
|
let paintScheduled = false;
|
|
14256
|
+
let lastPaintAt = 0; // when the last frame actually went to the terminal
|
|
14162
14257
|
// Deadline for the next FULL repaint. A differential painter compares against the frame
|
|
14163
14258
|
// it BELIEVES it painted, never the real screen, so any divergence between the two is
|
|
14164
14259
|
// permanent: it survives every later frame because the comparison keeps saying "that row
|
|
@@ -14193,6 +14288,7 @@ export async function startTUI(opts) {
|
|
|
14193
14288
|
state._hitboxes = frame.hitboxes || [];
|
|
14194
14289
|
state._composerMeta = frame.composerMeta || [];
|
|
14195
14290
|
if (out) stdout.write(out);
|
|
14291
|
+
lastPaintAt = now;
|
|
14196
14292
|
// "Painted" becomes observable here: everything that reaches the screen goes
|
|
14197
14293
|
// through this one function, so the tick advancing means bytes left this process.
|
|
14198
14294
|
// See src/flush-tick.js for what reads it.
|
|
@@ -14239,11 +14335,26 @@ export async function startTUI(opts) {
|
|
|
14239
14335
|
*/
|
|
14240
14336
|
function countOutputTokens(n) { return sessHost.countOutputTokens(n); }
|
|
14241
14337
|
|
|
14242
|
-
|
|
14243
|
-
|
|
14244
|
-
|
|
14245
|
-
|
|
14246
|
-
|
|
14338
|
+
/**
|
|
14339
|
+
* The render rate this session is capped at, from `/settings` → Render FPS.
|
|
14340
|
+
*
|
|
14341
|
+
* Read on every call rather than cached: the setting can change mid-session, and a cached rate would
|
|
14342
|
+
* only take effect after a restart.
|
|
14343
|
+
*/
|
|
14344
|
+
function renderFps() {
|
|
14345
|
+
const v = Number(cfg && cfg.render_fps);
|
|
14346
|
+
// A terminal cannot render at 0Hz; the lowest meaningful rate is one frame a second.
|
|
14347
|
+
return Number.isFinite(v) ? Math.max(1, Math.round(v)) : 30;
|
|
14348
|
+
}
|
|
14349
|
+
|
|
14350
|
+
function renderSoon() {
|
|
14351
|
+
if (paintScheduled) return;
|
|
14352
|
+
const budget = Math.max(1, Math.round(1000 / renderFps()));
|
|
14353
|
+
const wait = Math.max(0, (lastPaintAt || 0) + budget - Date.now());
|
|
14354
|
+
paintScheduled = true;
|
|
14355
|
+
if (wait === 0) { setImmediate(paintNow); return; }
|
|
14356
|
+
setTimeout(paintNow, wait);
|
|
14357
|
+
}
|
|
14247
14358
|
function openPicker(p) {
|
|
14248
14359
|
let footer = p.footer ? { focused: false, ...p.footer } : null;
|
|
14249
14360
|
if (p.footerFor) {
|
|
@@ -15353,7 +15464,7 @@ export async function startTUI(opts) {
|
|
|
15353
15464
|
],
|
|
15354
15465
|
resolve,
|
|
15355
15466
|
};
|
|
15356
|
-
|
|
15467
|
+
renderSoon();
|
|
15357
15468
|
}),
|
|
15358
15469
|
}))
|
|
15359
15470
|
.then((res) => {
|
|
@@ -16588,7 +16699,7 @@ export async function startTUI(opts) {
|
|
|
16588
16699
|
// swallowing them bought nothing and cost the one thing a long plan needs: a way to
|
|
16589
16700
|
// read past the fold. Everything else still belongs to the review.
|
|
16590
16701
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
16591
|
-
scrollChat(state, t.key === 'wheelup' ?
|
|
16702
|
+
scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
|
|
16592
16703
|
renderFrame();
|
|
16593
16704
|
return;
|
|
16594
16705
|
}
|
|
@@ -16811,7 +16922,7 @@ export async function startTUI(opts) {
|
|
|
16811
16922
|
// answer, so "own ALL input" does not need them — and without this a long question
|
|
16812
16923
|
// with an option list taller than the screen could not be read past at all.
|
|
16813
16924
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
16814
|
-
scrollChat(state, t.key === 'wheelup' ?
|
|
16925
|
+
scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
|
|
16815
16926
|
renderFrame();
|
|
16816
16927
|
return;
|
|
16817
16928
|
}
|
|
@@ -16833,7 +16944,7 @@ export async function startTUI(opts) {
|
|
|
16833
16944
|
// its own arrow behaviour on the un-modified keys.
|
|
16834
16945
|
if (t.key === 'c-up' || t.key === 'c-down') {
|
|
16835
16946
|
if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
|
|
16836
|
-
scrollChat(state, t.key === 'c-up' ?
|
|
16947
|
+
scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
|
|
16837
16948
|
renderFrame();
|
|
16838
16949
|
return;
|
|
16839
16950
|
}
|
|
@@ -16842,6 +16953,21 @@ export async function startTUI(opts) {
|
|
|
16842
16953
|
// Ctrl+L — expand / collapse the plugin log block(s). The last one is the
|
|
16843
16954
|
// useful target (it is the most recent), but every pluginLog message is toggled
|
|
16844
16955
|
// so a session with several reloads behaves predictably.
|
|
16956
|
+
if (t.key === 'alt-r') {
|
|
16957
|
+
// Only when nothing owns the keyboard: a picker or a dialog has its own meaning for R.
|
|
16958
|
+
if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
|
|
16959
|
+
const current = (cfg && cfg.provider) || '';
|
|
16960
|
+
if (!current) {
|
|
16961
|
+
notice('No provider selected — use /model to pick one.', 'error');
|
|
16962
|
+
} else {
|
|
16963
|
+
// Fire and forget: the notice reports the outcome, and blocking the key handler on a network
|
|
16964
|
+
// round-trip would freeze input for as long as the endpoint takes.
|
|
16965
|
+
void fetchAndRegisterModels(state, cfg, current, { notice: (m, k) => notice(m, k) });
|
|
16966
|
+
}
|
|
16967
|
+
renderFrame();
|
|
16968
|
+
return;
|
|
16969
|
+
}
|
|
16970
|
+
}
|
|
16845
16971
|
if (t.key === 'c-l') {
|
|
16846
16972
|
if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
|
|
16847
16973
|
const logs = (state.chat || []).filter((m) => m && m.pluginLog);
|
|
@@ -17084,7 +17210,7 @@ export async function startTUI(opts) {
|
|
|
17084
17210
|
renderFrame(); return;
|
|
17085
17211
|
}
|
|
17086
17212
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
17087
|
-
scrollChat(state, t.key === 'wheelup' ?
|
|
17213
|
+
scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
|
|
17088
17214
|
renderFrame(); return;
|
|
17089
17215
|
}
|
|
17090
17216
|
if (t.key === 'up' || t.key === 'down') {
|
|
@@ -17565,12 +17691,13 @@ export async function startTUI(opts) {
|
|
|
17565
17691
|
return;
|
|
17566
17692
|
}
|
|
17567
17693
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
17568
|
-
//
|
|
17569
|
-
//
|
|
17570
|
-
//
|
|
17571
|
-
|
|
17572
|
-
|
|
17573
|
-
const
|
|
17694
|
+
// HOW HARD THE USER IS TURNING, from the interval between key events.
|
|
17695
|
+
//
|
|
17696
|
+
// One rate cannot serve both gestures: a fixed one row makes a fast spin crawl, a fixed three jumps
|
|
17697
|
+
// past what a careful reader wanted. The interval between events is the speed, and it is already
|
|
17698
|
+
// in the stream — no timer, no separate gesture detection. Slow: one row. Fast: up to eight.
|
|
17699
|
+
const step = wheelStep(state, "wheel");
|
|
17700
|
+
const d = t.key === 'wheelup' ? step : -step;
|
|
17574
17701
|
if (state.panel) {
|
|
17575
17702
|
const p = state.panel;
|
|
17576
17703
|
p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
|
|
@@ -17934,6 +18061,26 @@ export async function startTUI(opts) {
|
|
|
17934
18061
|
// Read live, so a /external flip applies to the very next tool call.
|
|
17935
18062
|
function externalAllowed() { return sessHost.externalAllowed(); }
|
|
17936
18063
|
|
|
18064
|
+
/**
|
|
18065
|
+
* How many rows one notch or keypress should move, from how fast they are arriving.
|
|
18066
|
+
*
|
|
18067
|
+
* Shared by the wheel and by Ctrl+Up/Down so the two cannot drift: they are the same gesture with
|
|
18068
|
+
* different hardware. The EVENT STREAM is the speed sensor — a wheel turned slowly sends notches far
|
|
18069
|
+
* apart and one spun sends them in a burst, and a key held down repeats at the OS repeat rate.
|
|
18070
|
+
*
|
|
18071
|
+
* `family` keeps the two clocks apart: alternating a wheel notch with a Ctrl+Up should not be read as one
|
|
18072
|
+
* burst of either. It also makes the FIRST event of a family one row — `Infinity` lands in the slowest
|
|
18073
|
+
* step, so nothing jumps on the first touch after a pause.
|
|
18074
|
+
*/
|
|
18075
|
+
function wheelStep(state, family) {
|
|
18076
|
+
const now = Date.now();
|
|
18077
|
+
const at = state._stepAt || (state._stepAt = {});
|
|
18078
|
+
const since = at[family] ? now - at[family] : Infinity;
|
|
18079
|
+
at[family] = now;
|
|
18080
|
+
// 140ms is about the gap between deliberate notches; under 55ms they are arriving in a burst.
|
|
18081
|
+
return since > 140 ? 1 : since > 90 ? 2 : since > 55 ? 4 : 8;
|
|
18082
|
+
}
|
|
18083
|
+
|
|
17937
18084
|
function scrollChat(state, delta) {
|
|
17938
18085
|
const cols = dims().cols, rows = dims().rows;
|
|
17939
18086
|
// Only the TOTAL row count is needed to clamp the scroll position. This used to
|
|
@@ -18187,7 +18334,48 @@ export async function startTUI(opts) {
|
|
|
18187
18334
|
// calls, which is what keeps the two front-ends behaviourally identical.
|
|
18188
18335
|
// Straight through to the core. It validates and throws in the same shape as before, so
|
|
18189
18336
|
// the HTTP layer (which maps a throw to 409) is unaffected.
|
|
18190
|
-
|
|
18337
|
+
// `opts.new` starts a FRESH session before the prompt runs.
|
|
18338
|
+
//
|
|
18339
|
+
// The caller does not know the new session's id in advance, so asking them to create one first
|
|
18340
|
+
// means two requests and a round trip to learn an id that this side invents anyway. Passing
|
|
18341
|
+
// `new: true` lets the daemon accept a prompt against any session URL and have it run in a new
|
|
18342
|
+
// session instead — the same thing `/new` does, minus the slash command.
|
|
18343
|
+
hub.actions.submit = async (text, opts) => {
|
|
18344
|
+
if (opts && opts.new) {
|
|
18345
|
+
// Flush the outgoing session first: its todos and transcript belong to the id being replaced,
|
|
18346
|
+
// and `dispatch` does exactly what `/new` does, so the two cannot drift apart.
|
|
18347
|
+
await core.dispatch('new', '');
|
|
18348
|
+
// The hub's rows and event ring belong to the session that just ended. Clearing them keeps a
|
|
18349
|
+
// viewer from showing the previous conversation under the new id.
|
|
18350
|
+
hub.rows = [];
|
|
18351
|
+
hub.events = [];
|
|
18352
|
+
}
|
|
18353
|
+
// `core.submit` AWAITS THE WHOLE TURN, so its result is the finished turn — no polling needed.
|
|
18354
|
+
const out = await core.submit(text);
|
|
18355
|
+
// `opts.return` asks for the transcript of what just ran:
|
|
18356
|
+
//
|
|
18357
|
+
// "turn" the rows of this turn, tool results included
|
|
18358
|
+
// "full" every row the session holds
|
|
18359
|
+
// "none" (default) whatever `core.submit` returned, unchanged
|
|
18360
|
+
//
|
|
18361
|
+
// Without this a caller had to submit, wait an unknown time, then issue a second request for
|
|
18362
|
+
// `/api/state` — two round trips and a guess at how long the turn would take.
|
|
18363
|
+
const want = opts && opts.return;
|
|
18364
|
+
if (!want || want === 'none') return out;
|
|
18365
|
+
// FORCE ONE SYNC: `syncChat` does nothing while no browser is subscribed, so `hub.rows` would be
|
|
18366
|
+
// empty here and the caller would get an empty transcript from a turn that plainly ran.
|
|
18367
|
+
try { hub.syncChat(state.chat || []); } catch { /* the reply still carries `result` */ }
|
|
18368
|
+
// The turn begins at the last user row; everything after it is what the prompt produced.
|
|
18369
|
+
const rows = Array.isArray(hub.rows) ? hub.rows : [];
|
|
18370
|
+
let from = 0;
|
|
18371
|
+
for (let k = rows.length - 1; k >= 0; k--) { if (rows[k] && rows[k].role === 'user') { from = k; break; } }
|
|
18372
|
+
const slice = want === 'full' ? rows : rows.slice(from);
|
|
18373
|
+
const detail = (opts && opts.detail) || 'full';
|
|
18374
|
+
const shaped = detail === 'minimal' ? slice.filter((r) => r.role !== 'tool')
|
|
18375
|
+
: detail === 'summary' ? slice.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r))
|
|
18376
|
+
: slice;
|
|
18377
|
+
return { result: out, rows: shaped, detail, sessionId: session && session.id };
|
|
18378
|
+
};
|
|
18191
18379
|
hub.actions.dispatch = async (cmd, arg) => {
|
|
18192
18380
|
const out = await core.dispatch(cmd, arg);
|
|
18193
18381
|
// The core runs the command; the repaint is this front end's business, so it stays
|