@hncode/hncode 0.6.0 → 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 +37 -37
- package/src/clipboard.js +221 -33
- package/src/tools/goal.js +93 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +419 -130
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
|
|
@@ -66,6 +66,7 @@ import { paintRows, screenToRows } from './screen-paint.js';
|
|
|
66
66
|
import { diffScreens, placementKey, placementRows, setImageRegenerator } from './screen-diff.js';
|
|
67
67
|
import { render as renderMermaid, sourceBox as mermaidSourceBox, toAnsi as mermaidAnsi } from './mermaid/index.js';
|
|
68
68
|
import { getTool, toolNames } from './tools/index.js';
|
|
69
|
+
import { installGoalStore, getGoal } from './tools/goal.js';
|
|
69
70
|
import { decideFromRules, describeRules, selfTestRules, describeSelfTest } from './permissions.js';
|
|
70
71
|
import { isDestructiveCommand as shellIsDestructiveCommand, isReadOnlyCommand as shellIsReadOnlyCommand } from './shell-safety.js';
|
|
71
72
|
import { usdRate, syncRate, convert, normalizeCurrency, SUPPORTED_CURRENCIES } from './fx.js';
|
|
@@ -792,12 +793,19 @@ function lightMargin(n) {
|
|
|
792
793
|
/**
|
|
793
794
|
* The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
|
|
794
795
|
*
|
|
795
|
-
* `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only
|
|
796
|
-
* 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.
|
|
797
805
|
*/
|
|
798
806
|
export function crossMs(count, stepMs = STEP_MS) {
|
|
799
807
|
const cells = Math.max(1, count) + 2 * lightMargin(count);
|
|
800
|
-
return cells * stepMs;
|
|
808
|
+
return cells * stepMs * (SWEEP_TICKS / 10);
|
|
801
809
|
}
|
|
802
810
|
|
|
803
811
|
/**
|
|
@@ -927,8 +935,15 @@ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS
|
|
|
927
935
|
// written to fix, and which a flat ten reintroduces on a wide band.
|
|
928
936
|
const margin = lightMargin(n);
|
|
929
937
|
const travel = n + 2 * margin;
|
|
930
|
-
|
|
931
|
-
|
|
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;
|
|
932
947
|
// `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
|
|
933
948
|
// where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
|
|
934
949
|
// is a module-level function and the TUI state lives inside startTUI's closure — the trap that once
|
|
@@ -955,27 +970,13 @@ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS
|
|
|
955
970
|
* @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
|
|
956
971
|
* @returns {string} ANSI, ready to splice into a row
|
|
957
972
|
*/
|
|
958
|
-
function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS
|
|
959
|
-
// THE SWEEP PACE IS SET BY THE ROW, NOT BY THE WORD.
|
|
960
|
-
//
|
|
961
|
-
// A tool name is four characters (`Read`) in a row of eighty. Sweeping the four made the light cross in a
|
|
962
|
-
// few frames — each character changed colour inside one frame, which reads as a SNAP, and the harder you
|
|
963
|
-
// look the less it looks like motion. The fix is not to sweep the empty cells instead (they are never
|
|
964
|
-
// painted, so the light would be invisible for most of its travel and the word would sit dark); it is to
|
|
965
|
-
// run the SAME sweep geometry over a wider notional row and place the glyphs inside it, which slows the
|
|
966
|
-
// light down without moving where it appears.
|
|
967
|
-
//
|
|
968
|
-
// `lightAt` measures its geometry in cells, so the pace comes from `count`. Passing the row's width makes
|
|
969
|
-
// the crossing take proportionally longer; the glyphs stay at their own indices, and the light still visits
|
|
970
|
-
// every one of them, just over more frames.
|
|
973
|
+
function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
|
|
971
974
|
const s = String(text == null ? '' : text);
|
|
972
975
|
const n = s.length;
|
|
973
976
|
if (!n) return '';
|
|
974
|
-
// Glyphs keep their own positions; only the timing is stretched. `sweepWidth` is the row's usable width.
|
|
975
|
-
const pace = sweepWidth > n ? Math.round(sweepWidth) : n;
|
|
976
977
|
const out = [];
|
|
977
978
|
for (let i = 0; i < n; i++) {
|
|
978
|
-
const t = Math.max(0, Math.min(1, lightAt(phaseMs, i,
|
|
979
|
+
const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
|
|
979
980
|
const r = Math.round(base[0] + (glint[0] - base[0]) * t);
|
|
980
981
|
const g = Math.round(base[1] + (glint[1] - base[1]) * t);
|
|
981
982
|
const b = Math.round(base[2] + (glint[2] - base[2]) * t);
|
|
@@ -1009,7 +1010,12 @@ export const STEP_CHARS = 1;
|
|
|
1009
1010
|
// edge of the band to the settle point, i.e. `travel = 1 + BAND`, not `1 + 2*BAND`.
|
|
1010
1011
|
// Correcting that buys ~25% more frames for free, because the extra was spent sweeping
|
|
1011
1012
|
// through empty space before and after the row.
|
|
1012
|
-
|
|
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)
|
|
1013
1019
|
const SWEEP_TRAVEL_PAD = 1; // head runs from -BAND to 1
|
|
1014
1020
|
|
|
1015
1021
|
/**
|
|
@@ -2203,12 +2209,8 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
|
|
|
2203
2209
|
// that whole life: one origin, and elapsed grows from there.
|
|
2204
2210
|
const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}` : `tool:${name}`;
|
|
2205
2211
|
const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
|
|
2206
|
-
//
|
|
2207
|
-
|
|
2208
|
-
// light had moved a fifth of the way, and the name would sit at one brightness forever.
|
|
2209
|
-
const spentW = visualCol('● ') + visualCol(verb) + 1;
|
|
2210
|
-
const sweepW = width > 0 ? Math.max(name.length, width - spentW) : name.length;
|
|
2211
|
-
const phaseMs = elapsedMs % cycleMs(sweepW, TOOL_STEP_MS);
|
|
2212
|
+
// ONE shimmer for both rows — see `shimmerText`.
|
|
2213
|
+
const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
|
|
2212
2214
|
|
|
2213
2215
|
// The same restraint as the Working row's glint: the light is the base colour with a small lift in
|
|
2214
2216
|
// the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
|
|
@@ -2220,7 +2222,7 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
|
|
|
2220
2222
|
if (reducedMotion) {
|
|
2221
2223
|
nameDisplay = col(name, C.cyan);
|
|
2222
2224
|
} else {
|
|
2223
|
-
nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS
|
|
2225
|
+
nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS);
|
|
2224
2226
|
}
|
|
2225
2227
|
} else {
|
|
2226
2228
|
// Non-pending: show in plain cyan
|
|
@@ -4475,6 +4477,9 @@ function sparkChart(values, width, height) {
|
|
|
4475
4477
|
// shows at least 1%, clamped to [0, 100]. A non-positive or non-finite `max`
|
|
4476
4478
|
// reports 0.
|
|
4477
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;
|
|
4478
4483
|
if (!Number.isFinite(max) || max <= 0) return 0;
|
|
4479
4484
|
return Math.min(100, Math.max(0, Math.ceil((used / max) * 100)));
|
|
4480
4485
|
}
|
|
@@ -6022,9 +6027,18 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6022
6027
|
fg = C.scrollTrackFg;
|
|
6023
6028
|
}
|
|
6024
6029
|
const bg = C.scrollTrackBg;
|
|
6025
|
-
// A bitmap row is a terminal payload
|
|
6026
|
-
//
|
|
6027
|
-
|
|
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
|
+
}
|
|
6028
6042
|
}
|
|
6029
6043
|
body.push(row);
|
|
6030
6044
|
// Only the window reaches the frame; the rest lives in `body` for the diff to compare.
|
|
@@ -6329,6 +6343,9 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6329
6343
|
// `qs.index` selects a question; `qs.index === qs.items.length` selects the
|
|
6330
6344
|
// Other tab. The current tab is highlighted (background).
|
|
6331
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 = [];
|
|
6332
6349
|
{
|
|
6333
6350
|
let tabs = '';
|
|
6334
6351
|
const tabName = (i) => {
|
|
@@ -6338,7 +6355,10 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6338
6355
|
for (let i = 0; i <= qs.items.length; i++) {
|
|
6339
6356
|
const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
|
|
6340
6357
|
const on = i === qs.index;
|
|
6341
|
-
|
|
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 });
|
|
6342
6362
|
tabs += ' ';
|
|
6343
6363
|
}
|
|
6344
6364
|
box.push(boxRow(fitAnsi(tabs, innerW - 2)));
|
|
@@ -6410,7 +6430,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6410
6430
|
const boxTop = lines.length;
|
|
6411
6431
|
for (const b of box) lines.push(b);
|
|
6412
6432
|
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
6413
|
-
|
|
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 }));
|
|
6414
6442
|
if (caretInBox >= 0) {
|
|
6415
6443
|
// The box is mid-frame (chat above, composer below), NOT pinned to the top,
|
|
6416
6444
|
// so its caret is recorded as an absolute frame row and corrected with the
|
|
@@ -6597,11 +6625,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6597
6625
|
// started its sweep from an origin minutes in the past — a phase far past the cycle, so the light
|
|
6598
6626
|
// snapped to a position mid-row. Keying on the turn's own start stamp makes every animation unique.
|
|
6599
6627
|
const turnKey = state.turnStart || 0;
|
|
6600
|
-
|
|
6601
|
-
// to the phrase would wrap while a row-wide light had barely moved, and the phrase would hold one
|
|
6602
|
-
// brightness for the whole turn.
|
|
6603
|
-
const workSweep = Math.max(workMsg.length, cols - 2);
|
|
6604
|
-
const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workSweep);
|
|
6628
|
+
const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workMsg.length);
|
|
6605
6629
|
|
|
6606
6630
|
// THE GLINT IS A STEP AWAY FROM THE BASE, not a jump to another colour.
|
|
6607
6631
|
//
|
|
@@ -6617,13 +6641,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6617
6641
|
const chars = workMsg;
|
|
6618
6642
|
const lightEdge = state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge) || 'cosine';
|
|
6619
6643
|
// ONE shimmer for both rows — see `shimmerText`.
|
|
6620
|
-
|
|
6621
|
-
//
|
|
6622
|
-
// `Working...` is ten characters in a row that has eighty columns. Sweeping only the ten made the light
|
|
6623
|
-
// move one glyph per frame — a snap rather than a fade. The sweep now covers the room the row actually
|
|
6624
|
-
// has, so the same phrase animates at a speed the eye reads as motion. Only the phrase's own cells are
|
|
6625
|
-
// painted; a coloured space would smear the row's padding.
|
|
6626
|
-
const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge, STEP_MS, Math.max(chars.length, cols - 2));
|
|
6644
|
+
const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge);
|
|
6627
6645
|
// A QUIET TURN FADES TOWARD THE WARNING COLOUR.
|
|
6628
6646
|
//
|
|
6629
6647
|
// `state.lastTokenAt` is stamped whenever output arrives; `state.hasActiveTools` is set while a tool
|
|
@@ -6644,24 +6662,34 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6644
6662
|
}
|
|
6645
6663
|
|
|
6646
6664
|
if (!takeover && state.todos && state.todos.length) {
|
|
6647
|
-
//
|
|
6648
|
-
//
|
|
6649
|
-
//
|
|
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.
|
|
6650
6671
|
const todoTopRow = lines.length;
|
|
6651
|
-
|
|
6652
|
-
|
|
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);
|
|
6653
6678
|
}
|
|
6654
6679
|
|
|
6655
6680
|
// Queued (not yet sent) messages, directly above the composer: these are what
|
|
6656
6681
|
// Ctrl-S steers into the running turn. See renderQueuePanel.
|
|
6657
6682
|
if (!takeover && state.queued && state.queued.length) {
|
|
6658
|
-
//
|
|
6659
|
-
// the mouse handler can hover/press it.
|
|
6683
|
+
// Same rule as the todo panel: the header is the handle, not every row.
|
|
6660
6684
|
const queueTopRow = lines.length;
|
|
6661
|
-
|
|
6662
|
-
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);
|
|
6663
6690
|
}
|
|
6664
6691
|
|
|
6692
|
+
|
|
6665
6693
|
// Side-thread box (/btw), docked directly above the composer — the placement kimi
|
|
6666
6694
|
// uses (its panelContainer sits over the editor with `connectedAbove = true`), so
|
|
6667
6695
|
// the conversation being asked about stays visible while you keep typing.
|
|
@@ -7647,11 +7675,25 @@ export function makeState({ cfg, session, opts }) {
|
|
|
7647
7675
|
cwd,
|
|
7648
7676
|
cwdOverride: null,
|
|
7649
7677
|
tip: '',
|
|
7650
|
-
|
|
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,
|
|
7651
7689
|
// Set once the endpoint reports a real context size; from then on the local estimate is not used.
|
|
7652
|
-
ctxReportedOnce:
|
|
7653
|
-
ctxTokens:
|
|
7654
|
-
|
|
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),
|
|
7655
7697
|
rounds: (session && session.rounds) || 0,
|
|
7656
7698
|
turnStart: 0,
|
|
7657
7699
|
steps: (session && session.steps) || 0,
|
|
@@ -9164,18 +9206,23 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
9164
9206
|
|
|
9165
9207
|
// Update context gauge based on restored session messages.
|
|
9166
9208
|
//
|
|
9167
|
-
// THE
|
|
9168
|
-
//
|
|
9169
|
-
//
|
|
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.
|
|
9170
9213
|
//
|
|
9171
|
-
//
|
|
9172
|
-
//
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
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
|
+
}
|
|
9177
9223
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
9178
|
-
|
|
9224
|
+
// THE PERCENTAGE MUST BE COMPUTED FROM THE SAME NUMBER IT IS SHOWN WITH.
|
|
9225
|
+
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax);
|
|
9179
9226
|
|
|
9180
9227
|
const dur = session.lastTurnMs ? ` · last turn ${fmtDuration(session.lastTurnMs)}` : '';
|
|
9181
9228
|
app(`Resumed ${meta.id} (${meta.title || 'untitled'}) · ${(session.messages || []).length} messages.${dur}`);
|
|
@@ -9496,8 +9543,23 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
9496
9543
|
// _conv: true}` instead of rows: a message with a role of `undefined`, which
|
|
9497
9544
|
// renders as blank. That is what made the transcript go empty after a manual
|
|
9498
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
|
+
}
|
|
9499
9561
|
state.chat = [
|
|
9500
|
-
...
|
|
9562
|
+
...promptRows,
|
|
9501
9563
|
{ role: 'user', text: compactedSummary, _conv: true },
|
|
9502
9564
|
...kept.flatMap((m) => convRowFor(m).map((r) => ({ ...r, _conv: true }))),
|
|
9503
9565
|
];
|
|
@@ -9506,7 +9568,9 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
9506
9568
|
// Same formula as everywhere else: messages PLUS the tool schemas.
|
|
9507
9569
|
state.tokens = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
|
|
9508
9570
|
if (!state.ctxReportedOnce) state.ctxTokens = state.tokens;
|
|
9509
|
-
|
|
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);
|
|
9510
9574
|
// The transcript was just replaced with a much shorter one. Drop the scroll
|
|
9511
9575
|
// anchor (as /sessions and /move do) so the next paint seeds a fresh one and
|
|
9512
9576
|
// the view lands on the newest rows instead of compensating for a row delta
|
|
@@ -9962,21 +10026,30 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
9962
10026
|
|
|
9963
10027
|
case 'goal': {
|
|
9964
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
|
+
};
|
|
9965
10037
|
if (!raw || a === 'status') {
|
|
9966
10038
|
if (state.objective) sayPanel(`Goal: ${state.objective}\nStatus: ${state.goalPaused ? 'paused' : 'active'}`);
|
|
9967
10039
|
else say('No goal set. Start one with /goal <objective>.');
|
|
9968
10040
|
return;
|
|
9969
10041
|
}
|
|
9970
|
-
if (a === 'pause') { state.
|
|
10042
|
+
if (a === 'pause') { applyGoal(state.objective, true); app('Goal paused. Use /goal resume to continue.'); return; }
|
|
9971
10043
|
if (a === 'resume') {
|
|
9972
10044
|
if (!state.objective) { app('No goal to resume.'); return; }
|
|
9973
|
-
state.
|
|
10045
|
+
applyGoal(state.objective, false);
|
|
10046
|
+
app('Goal resumed.');
|
|
9974
10047
|
sendPrompt('Resume the active goal.');
|
|
9975
10048
|
return;
|
|
9976
10049
|
}
|
|
9977
|
-
if (a === 'cancel') {
|
|
9978
|
-
if (a.startsWith('replace ')) {
|
|
9979
|
-
|
|
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);
|
|
9980
10053
|
app(`Goal set: ${raw}`);
|
|
9981
10054
|
sendPrompt(raw);
|
|
9982
10055
|
return;
|
|
@@ -13680,6 +13753,17 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
13680
13753
|
state._turnSteps = (state._turnSteps || 0) + 1;
|
|
13681
13754
|
state.steps = (state._stepsBase || 0) + state._turnSteps;
|
|
13682
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
|
+
}
|
|
13683
13767
|
// Persist at every step boundary, not only at turn end. `messages` is the
|
|
13684
13768
|
// SAME array this Agent appends to, so copying it here captures every
|
|
13685
13769
|
// completed step: a crash mid-turn then loses at most the step in flight
|
|
@@ -14294,7 +14378,9 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14294
14378
|
const approx = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
|
|
14295
14379
|
if (!state.ctxReportedOnce) state.ctxTokens = approx;
|
|
14296
14380
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
14297
|
-
|
|
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);
|
|
14298
14384
|
// `session.messages` now holds the full request, system prompt included, so the bar
|
|
14299
14385
|
// reads the real text from here on and the turn-scoped copy must not be counted too.
|
|
14300
14386
|
state.turnSystemPrompt = null;
|
|
@@ -14388,6 +14474,37 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14388
14474
|
// Queue shrank: push the updated list to the web queue pane immediately.
|
|
14389
14475
|
try { viewCur.pubStatusField('queued', [...state.queued]); } catch { /* best-effort */ }
|
|
14390
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
|
+
}
|
|
14391
14508
|
// Approved plan: Plan mode OFF (write tools come back), then re-enter the agent
|
|
14392
14509
|
// with the plan as a SYSTEM message. Deliberately NOT via submit(): that path
|
|
14393
14510
|
// posts a user bubble and re-runs the whole submit flow, which is what produced
|
|
@@ -15526,6 +15643,18 @@ export async function startTUI(opts) {
|
|
|
15526
15643
|
// because `generateTitle` is a hoisted declaration in this same scope: it is defined far
|
|
15527
15644
|
// below, but the binding exists as soon as this statement runs.
|
|
15528
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
|
+
});
|
|
15529
15658
|
// The same indirection, for the same reason: `runTurn` drains the queue by re-entering
|
|
15530
15659
|
// `submit`, which is a nested function of THIS scope. Referencing it by name from `runTurn`
|
|
15531
15660
|
// threw `submit is not defined` and killed the process on the next turn — the identical
|
|
@@ -16555,10 +16684,21 @@ export async function startTUI(opts) {
|
|
|
16555
16684
|
// advance identically — duplicated logic here would drift.
|
|
16556
16685
|
function advanceQuestion(qs) {
|
|
16557
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
|
+
}
|
|
16558
16697
|
if (qs.index < total - 1) {
|
|
16559
16698
|
qs.index++;
|
|
16560
|
-
qs.
|
|
16561
|
-
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();
|
|
16562
16702
|
return;
|
|
16563
16703
|
}
|
|
16564
16704
|
qs.index = total; // the Other tab
|
|
@@ -16811,6 +16951,32 @@ export async function startTUI(opts) {
|
|
|
16811
16951
|
// itself has no per-row action, so absorbing is the whole behaviour.
|
|
16812
16952
|
return true;
|
|
16813
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
|
+
}
|
|
16814
16980
|
case 'questionItem': {
|
|
16815
16981
|
// Click an option in the AskUserQuestion dialog. `hb.index` is the position
|
|
16816
16982
|
// in [options..., Other] for the CURRENT question tab. Without this case the
|
|
@@ -17241,7 +17407,19 @@ export async function startTUI(opts) {
|
|
|
17241
17407
|
// now 7-12ms warm, so the notice did nothing except paint a frame, get replaced a moment later, and
|
|
17242
17408
|
// make the paste feel like it was waiting for something. The text appears, or an error is reported —
|
|
17243
17409
|
// both are visible without an intermediate state.
|
|
17244
|
-
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
|
+
}
|
|
17245
17423
|
if (text) {
|
|
17246
17424
|
// An image collapses to a `[paste #N image]` chip, the same way a multi-line
|
|
17247
17425
|
// paste becomes `[paste #N +L lines]`, so the prompt does not fill up with a
|
|
@@ -17276,8 +17454,14 @@ export async function startTUI(opts) {
|
|
|
17276
17454
|
renderFrame();
|
|
17277
17455
|
return;
|
|
17278
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.
|
|
17279
17462
|
const items = (state.queued || []).slice();
|
|
17280
|
-
const
|
|
17463
|
+
const rawDraft = String(state.input || '');
|
|
17464
|
+
const draft = rawDraft.trim() ? expandPastes(rawDraft, state.pastes).trim() : '';
|
|
17281
17465
|
if (draft) items.push(draft);
|
|
17282
17466
|
if (!items.length) {
|
|
17283
17467
|
notice('Nothing queued to steer', 'error');
|
|
@@ -17400,12 +17584,58 @@ export async function startTUI(opts) {
|
|
|
17400
17584
|
return keybindings;
|
|
17401
17585
|
}
|
|
17402
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
|
+
|
|
17403
17600
|
function handleKey(t) {
|
|
17404
17601
|
// A KITTY GRAPHICS REPLY IS NOT A KEYSTROKE. It is the terminal acknowledging an upload (`G i=31;OK`) or
|
|
17405
17602
|
// reporting that it dropped an image. The token exists so the bytes are CONSUMED rather than typed into the
|
|
17406
17603
|
// composer; there is nothing to act on yet, because a failed upload is handled by placing the formula as
|
|
17407
17604
|
// text next time rather than by retrying mid-frame.
|
|
17408
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
|
+
}
|
|
17409
17639
|
// USER KEYBINDINGS, their own layer. A binding either REPLACES a key with another key
|
|
17410
17640
|
// (applied by rewriting `t.key` below, so the whole switch sees the bound key without
|
|
17411
17641
|
// knowing bindings exist) or runs a slash command. Mouse and wheel events are never
|
|
@@ -17502,7 +17732,7 @@ export async function startTUI(opts) {
|
|
|
17502
17732
|
return;
|
|
17503
17733
|
}
|
|
17504
17734
|
if (t.key === 'pageup' || t.key === 'pagedown') {
|
|
17505
|
-
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
|
|
17506
17736
|
renderFrame();
|
|
17507
17737
|
return;
|
|
17508
17738
|
}
|
|
@@ -17604,10 +17834,18 @@ export async function startTUI(opts) {
|
|
|
17604
17834
|
if (qs.editing === 'other') qs.otherText = qs.editingText || '';
|
|
17605
17835
|
else if (qs.editing === 'supplement') qs.supplement = qs.editingText || '';
|
|
17606
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
|
+
}
|
|
17607
17844
|
const n = total + 1; // questions + the Other tab
|
|
17608
17845
|
qs.index = (qs.index + dir + n) % n;
|
|
17609
|
-
qs.
|
|
17610
|
-
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();
|
|
17611
17849
|
if (qs.index === total) {
|
|
17612
17850
|
// Landing on the Other tab opens its text field directly.
|
|
17613
17851
|
qs.editing = 'supplement';
|
|
@@ -17657,7 +17895,18 @@ export async function startTUI(opts) {
|
|
|
17657
17895
|
return;
|
|
17658
17896
|
}
|
|
17659
17897
|
if (!answer) { renderFrame(); return; } // nothing typed: stay
|
|
17660
|
-
|
|
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
|
+
}
|
|
17661
17910
|
qs.otherText = '';
|
|
17662
17911
|
qs.editing = null;
|
|
17663
17912
|
advance();
|
|
@@ -17707,7 +17956,13 @@ export async function startTUI(opts) {
|
|
|
17707
17956
|
if (t.key === 'enter') {
|
|
17708
17957
|
if (qs.sel >= otherIdx) { openEditor('other'); return; }
|
|
17709
17958
|
if (cur.multiSelect) {
|
|
17710
|
-
|
|
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);
|
|
17711
17966
|
if (labels.length) qs.answers[cur.question] = labels.join(', ');
|
|
17712
17967
|
advance();
|
|
17713
17968
|
return;
|
|
@@ -17725,7 +17980,7 @@ export async function startTUI(opts) {
|
|
|
17725
17980
|
return;
|
|
17726
17981
|
}
|
|
17727
17982
|
if (t.key === 'pageup' || t.key === 'pagedown') {
|
|
17728
|
-
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
|
|
17729
17984
|
renderFrame();
|
|
17730
17985
|
return;
|
|
17731
17986
|
}
|
|
@@ -18004,7 +18259,7 @@ export async function startTUI(opts) {
|
|
|
18004
18259
|
return;
|
|
18005
18260
|
}
|
|
18006
18261
|
if (t.key === 'pageup' || t.key === 'pagedown') {
|
|
18007
|
-
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
|
|
18008
18263
|
renderFrame(); return;
|
|
18009
18264
|
}
|
|
18010
18265
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
@@ -18057,7 +18312,11 @@ export async function startTUI(opts) {
|
|
|
18057
18312
|
}
|
|
18058
18313
|
// Draft is on its last/first row: the key is free. Scroll the transcript
|
|
18059
18314
|
// (useful while output streams and you want to read back).
|
|
18060
|
-
|
|
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)));
|
|
18061
18320
|
renderFrame(); return;
|
|
18062
18321
|
}
|
|
18063
18322
|
}
|
|
@@ -18203,10 +18462,12 @@ export async function startTUI(opts) {
|
|
|
18203
18462
|
if (state.panel) {
|
|
18204
18463
|
const p = state.panel;
|
|
18205
18464
|
if (t.key === 'escape') { state.panel = null; state.hoverHit = null; renderFrame(); return; }
|
|
18206
|
-
|
|
18207
|
-
|
|
18208
|
-
if (t.key === '
|
|
18209
|
-
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; }
|
|
18210
18471
|
if (t.key === 'home') { p.top = 0; renderFrame(); return; }
|
|
18211
18472
|
return;
|
|
18212
18473
|
}
|
|
@@ -18412,7 +18673,7 @@ export async function startTUI(opts) {
|
|
|
18412
18673
|
// ↑/↓ scroll the box's history, in ROWS. Held on the state as an offset from
|
|
18413
18674
|
// the tail, matching the transcript's convention.
|
|
18414
18675
|
if (t.key === 'up' || t.key === 'down') {
|
|
18415
|
-
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));
|
|
18416
18677
|
state.btwScroll = Math.max(0, (state.btwScroll || 0) + step);
|
|
18417
18678
|
renderFrame();
|
|
18418
18679
|
return;
|
|
@@ -18592,7 +18853,7 @@ export async function startTUI(opts) {
|
|
|
18592
18853
|
}
|
|
18593
18854
|
return;
|
|
18594
18855
|
}
|
|
18595
|
-
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
|
|
18596
18857
|
renderFrame(); return;
|
|
18597
18858
|
}
|
|
18598
18859
|
// Shift+arrows EXTEND a composer selection instead of moving the caret. The
|
|
@@ -18637,13 +18898,24 @@ export async function startTUI(opts) {
|
|
|
18637
18898
|
|
|
18638
18899
|
if (t.key === 'left' || t.key === 'right') {
|
|
18639
18900
|
const dir = t.key === 'left' ? -1 : 1;
|
|
18640
|
-
|
|
18641
|
-
|
|
18642
|
-
|
|
18643
|
-
|
|
18644
|
-
|
|
18645
|
-
|
|
18646
|
-
|
|
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
|
+
}
|
|
18647
18919
|
}
|
|
18648
18920
|
// A horizontal move abandons the sticky column (kimi's setCursorCol).
|
|
18649
18921
|
state._caretPrefCol = null;
|
|
@@ -18730,21 +19002,29 @@ export async function startTUI(opts) {
|
|
|
18730
19002
|
renderFrame();
|
|
18731
19003
|
return;
|
|
18732
19004
|
}
|
|
18733
|
-
|
|
18734
|
-
|
|
18735
|
-
|
|
18736
|
-
|
|
18737
|
-
|
|
18738
|
-
|
|
18739
|
-
|
|
18740
|
-
|
|
18741
|
-
|
|
18742
|
-
state.input = state.input.slice(0,
|
|
18743
|
-
state.caret =
|
|
18744
|
-
|
|
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
|
+
}
|
|
18745
19026
|
} else {
|
|
18746
|
-
|
|
18747
|
-
state.caret--;
|
|
19027
|
+
break; // nothing left to delete; the remaining repeats would be no-ops
|
|
18748
19028
|
}
|
|
18749
19029
|
}
|
|
18750
19030
|
// Deleting can also change the mention (or delete the `@` itself), so the
|
|
@@ -18754,19 +19034,25 @@ export async function startTUI(opts) {
|
|
|
18754
19034
|
renderFrame(); state.composerSel = null; return;
|
|
18755
19035
|
}
|
|
18756
19036
|
if (t.key === 'delete') {
|
|
18757
|
-
|
|
18758
|
-
|
|
18759
|
-
|
|
18760
|
-
const
|
|
18761
|
-
|
|
18762
|
-
|
|
18763
|
-
|
|
18764
|
-
|
|
18765
|
-
|
|
18766
|
-
|
|
18767
|
-
state.
|
|
18768
|
-
|
|
18769
|
-
|
|
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
|
+
}
|
|
18770
19056
|
}
|
|
18771
19057
|
}
|
|
18772
19058
|
refreshMenu(state); renderFrame(); state.composerSel = null; return;
|
|
@@ -18957,6 +19243,9 @@ function wheelStep(state, family) {
|
|
|
18957
19243
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
18958
19244
|
state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
|
|
18959
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;
|
|
18960
19249
|
state.historyIdx = -1;
|
|
18961
19250
|
// The draft belongs to the history walk that just ended; the next ↑ starts fresh.
|
|
18962
19251
|
state.draftInput = '';
|