@hncode/hncode 0.6.0 → 0.6.2
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 +421 -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,32 @@ 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. `persist` is this function's own wrapper around `h.persistState` — `sessHost` is
|
|
10031
|
+
// a binding inside `startTUI` and does not exist at module level, which is what made `/goal` throw
|
|
10032
|
+
// `sessHost is not defined`.
|
|
10033
|
+
const applyGoal = (value, paused) => {
|
|
10034
|
+
state.objective = String(value || '');
|
|
10035
|
+
state.goalPaused = !!paused;
|
|
10036
|
+
persist();
|
|
10037
|
+
renderFrame();
|
|
10038
|
+
};
|
|
9965
10039
|
if (!raw || a === 'status') {
|
|
9966
10040
|
if (state.objective) sayPanel(`Goal: ${state.objective}\nStatus: ${state.goalPaused ? 'paused' : 'active'}`);
|
|
9967
10041
|
else say('No goal set. Start one with /goal <objective>.');
|
|
9968
10042
|
return;
|
|
9969
10043
|
}
|
|
9970
|
-
if (a === 'pause') { state.
|
|
10044
|
+
if (a === 'pause') { applyGoal(state.objective, true); app('Goal paused. Use /goal resume to continue.'); return; }
|
|
9971
10045
|
if (a === 'resume') {
|
|
9972
10046
|
if (!state.objective) { app('No goal to resume.'); return; }
|
|
9973
|
-
state.
|
|
10047
|
+
applyGoal(state.objective, false);
|
|
10048
|
+
app('Goal resumed.');
|
|
9974
10049
|
sendPrompt('Resume the active goal.');
|
|
9975
10050
|
return;
|
|
9976
10051
|
}
|
|
9977
|
-
if (a === 'cancel') {
|
|
9978
|
-
if (a.startsWith('replace ')) {
|
|
9979
|
-
|
|
10052
|
+
if (a === 'cancel') { applyGoal('', false); app('Goal cancelled.'); return; }
|
|
10053
|
+
if (a.startsWith('replace ')) { applyGoal(raw.slice(8).trim(), false); app('Goal replaced.'); return; }
|
|
10054
|
+
applyGoal(raw, false);
|
|
9980
10055
|
app(`Goal set: ${raw}`);
|
|
9981
10056
|
sendPrompt(raw);
|
|
9982
10057
|
return;
|
|
@@ -13680,6 +13755,17 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
13680
13755
|
state._turnSteps = (state._turnSteps || 0) + 1;
|
|
13681
13756
|
state.steps = (state._stepsBase || 0) + state._turnSteps;
|
|
13682
13757
|
if (session) session.steps = state.steps;
|
|
13758
|
+
// THE GAUGE MOVES WITH EVERY STEP, not only when the provider answers.
|
|
13759
|
+
//
|
|
13760
|
+
// A provider reports usage with its REPLY, so between two replies the row kept the previous
|
|
13761
|
+
// figure while the conversation underneath it grew by a whole step — tool results in particular can
|
|
13762
|
+
// be huge. The estimate takes over for the in-between frames (it never overwrites a reported figure
|
|
13763
|
+
// once one has arrived), so the bar tracks the request actually being built instead of lagging one
|
|
13764
|
+
// step behind it.
|
|
13765
|
+
if (!state.ctxReportedOnce) {
|
|
13766
|
+
state.ctxTokens = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
|
|
13767
|
+
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || cfg.maxContextTokens || 1);
|
|
13768
|
+
}
|
|
13683
13769
|
// Persist at every step boundary, not only at turn end. `messages` is the
|
|
13684
13770
|
// SAME array this Agent appends to, so copying it here captures every
|
|
13685
13771
|
// completed step: a crash mid-turn then loses at most the step in flight
|
|
@@ -14294,7 +14380,9 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14294
14380
|
const approx = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
|
|
14295
14381
|
if (!state.ctxReportedOnce) state.ctxTokens = approx;
|
|
14296
14382
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
14297
|
-
|
|
14383
|
+
// Same rule as the resume path: once a provider has reported a size, the percentage comes from that
|
|
14384
|
+
// number, not from the estimate. Computing it from `approx` here made the row show `874k/1M` beside 73%.
|
|
14385
|
+
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax);
|
|
14298
14386
|
// `session.messages` now holds the full request, system prompt included, so the bar
|
|
14299
14387
|
// reads the real text from here on and the turn-scoped copy must not be counted too.
|
|
14300
14388
|
state.turnSystemPrompt = null;
|
|
@@ -14388,6 +14476,37 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14388
14476
|
// Queue shrank: push the updated list to the web queue pane immediately.
|
|
14389
14477
|
try { viewCur.pubStatusField('queued', [...state.queued]); } catch { /* best-effort */ }
|
|
14390
14478
|
}
|
|
14479
|
+
// THE GOAL RESTARTS THE TURN.
|
|
14480
|
+
//
|
|
14481
|
+
// An agent with an objective that spans several turns stops when it runs out of things to say in THIS
|
|
14482
|
+
// turn, which looks exactly like finishing. The objective is what tells the two apart: while one is set and
|
|
14483
|
+
// not paused, the agent is sent back in with the goal quoted, so it continues rather than waiting to be
|
|
14484
|
+
// poked. `Goal clear` is the only thing that stops the loop, which is why the tool says never to clear a
|
|
14485
|
+
// goal that has not been met.
|
|
14486
|
+
//
|
|
14487
|
+
// Skipped in every case where the turn did not end normally: a dismissal means the user wants it to stop,
|
|
14488
|
+
// and a queued message is already the next turn (restarting here would run both).
|
|
14489
|
+
const goalNow = String(state.objective || '').trim();
|
|
14490
|
+
const goalApplies = goalNow && !state.goalPaused && !approvedPlan && !state.queued.length
|
|
14491
|
+
&& !state._goalTurns; // set below, so one restart per goal per turn
|
|
14492
|
+
if (goalApplies) {
|
|
14493
|
+
state._goalTurns = (state._goalTurns || 0) + 1;
|
|
14494
|
+
viewCur.addChat({
|
|
14495
|
+
role: 'system',
|
|
14496
|
+
text: `[goal] continuing (${state._goalTurns}) — ${goalNow}`,
|
|
14497
|
+
});
|
|
14498
|
+
viewCur.render();
|
|
14499
|
+
// The prompt names the goal the agent itself wrote, and says what to do when it is done. Without the
|
|
14500
|
+
// second half the loop restarts forever: a model with no instruction to clear has no reason to.
|
|
14501
|
+
const continueText = [
|
|
14502
|
+
'You have an active goal and the turn ended before it was reached.',
|
|
14503
|
+
`Goal: ${goalNow}`,
|
|
14504
|
+
'',
|
|
14505
|
+
'Continue working on it. When the goal is fully met, call the Goal tool with action "clear" so the loop',
|
|
14506
|
+
'stops; if it is still not met, keep going.',
|
|
14507
|
+
].join('\n');
|
|
14508
|
+
void sessHost.submit(continueText);
|
|
14509
|
+
}
|
|
14391
14510
|
// Approved plan: Plan mode OFF (write tools come back), then re-enter the agent
|
|
14392
14511
|
// with the plan as a SYSTEM message. Deliberately NOT via submit(): that path
|
|
14393
14512
|
// posts a user bubble and re-runs the whole submit flow, which is what produced
|
|
@@ -15526,6 +15645,18 @@ export async function startTUI(opts) {
|
|
|
15526
15645
|
// because `generateTitle` is a hoisted declaration in this same scope: it is defined far
|
|
15527
15646
|
// below, but the binding exists as soon as this statement runs.
|
|
15528
15647
|
sessHost.makeTitle = generateTitle;
|
|
15648
|
+
// The Goal tool writes the SAME objective field /goal does, so the status panel and the turn loop cannot
|
|
15649
|
+
// disagree about what the agent is working towards. `runTurn` reaches both through `sessHost`, which is why
|
|
15650
|
+
// the store is installed here rather than inside the tool.
|
|
15651
|
+
installGoalStore({
|
|
15652
|
+
get: () => state.objective || '',
|
|
15653
|
+
set: (goal) => {
|
|
15654
|
+
state.objective = String(goal || '');
|
|
15655
|
+
state.goalPaused = false;
|
|
15656
|
+
try { sessHost.persistState(); } catch { /* a failed save must not lose the goal for this turn */ }
|
|
15657
|
+
renderFrame();
|
|
15658
|
+
},
|
|
15659
|
+
});
|
|
15529
15660
|
// The same indirection, for the same reason: `runTurn` drains the queue by re-entering
|
|
15530
15661
|
// `submit`, which is a nested function of THIS scope. Referencing it by name from `runTurn`
|
|
15531
15662
|
// threw `submit is not defined` and killed the process on the next turn — the identical
|
|
@@ -16555,10 +16686,21 @@ export async function startTUI(opts) {
|
|
|
16555
16686
|
// advance identically — duplicated logic here would drift.
|
|
16556
16687
|
function advanceQuestion(qs) {
|
|
16557
16688
|
const total = qs.items.length;
|
|
16689
|
+
// REMEMBER WHAT WAS CHOSEN ON THIS QUESTION BEFORE LEAVING IT.
|
|
16690
|
+
//
|
|
16691
|
+
// `sel` and `picked` were global to the dialog, so moving on reset both to "first row, nothing picked" —
|
|
16692
|
+
// and coming back to re-read or change an earlier answer showed the first option as the current one, with
|
|
16693
|
+
// no sign of what had actually been chosen. A per-question record keeps the row and the selection where
|
|
16694
|
+
// the user left them.
|
|
16695
|
+
if (qs.index < total) {
|
|
16696
|
+
if (!qs.remember) qs.remember = new Map();
|
|
16697
|
+
qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
|
|
16698
|
+
}
|
|
16558
16699
|
if (qs.index < total - 1) {
|
|
16559
16700
|
qs.index++;
|
|
16560
|
-
qs.
|
|
16561
|
-
qs.
|
|
16701
|
+
const back = qs.remember && qs.remember.get(qs.index);
|
|
16702
|
+
qs.sel = back ? back.sel : 0;
|
|
16703
|
+
qs.picked = back ? new Set(back.picked) : new Set();
|
|
16562
16704
|
return;
|
|
16563
16705
|
}
|
|
16564
16706
|
qs.index = total; // the Other tab
|
|
@@ -16811,6 +16953,32 @@ export async function startTUI(opts) {
|
|
|
16811
16953
|
// itself has no per-row action, so absorbing is the whole behaviour.
|
|
16812
16954
|
return true;
|
|
16813
16955
|
}
|
|
16956
|
+
case 'questionTab': {
|
|
16957
|
+
// Clicking a tab label switches to that question. `hb.index` is the tab's position; the trailing one is
|
|
16958
|
+
// the Other tab, whose field opens on arrival, exactly as `switchTab` does it. Without this case the
|
|
16959
|
+
// labels were plain text and the only way back to an earlier question was the keyboard.
|
|
16960
|
+
if (!state.question) return false;
|
|
16961
|
+
const qs = state.question;
|
|
16962
|
+
if (qs.editing === 'other') qs.otherText = qs.editingText || '';
|
|
16963
|
+
else if (qs.editing === 'supplement') qs.supplement = qs.editingText || '';
|
|
16964
|
+
qs.editing = null;
|
|
16965
|
+
const totalTabs = qs.items.length;
|
|
16966
|
+
if (qs.index < totalTabs) {
|
|
16967
|
+
if (!qs.remember) qs.remember = new Map();
|
|
16968
|
+
qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
|
|
16969
|
+
}
|
|
16970
|
+
qs.index = Math.max(0, Math.min(totalTabs, hb.index));
|
|
16971
|
+
const backTab = qs.index < totalTabs && qs.remember ? qs.remember.get(qs.index) : null;
|
|
16972
|
+
qs.sel = backTab ? backTab.sel : 0;
|
|
16973
|
+
qs.picked = backTab ? new Set(backTab.picked) : new Set();
|
|
16974
|
+
if (qs.index === totalTabs) {
|
|
16975
|
+
qs.editing = 'supplement';
|
|
16976
|
+
qs.editingText = qs.supplement || '';
|
|
16977
|
+
qs.editingCaret = qs.editingText.length;
|
|
16978
|
+
}
|
|
16979
|
+
renderFrame();
|
|
16980
|
+
return true;
|
|
16981
|
+
}
|
|
16814
16982
|
case 'questionItem': {
|
|
16815
16983
|
// Click an option in the AskUserQuestion dialog. `hb.index` is the position
|
|
16816
16984
|
// in [options..., Other] for the CURRENT question tab. Without this case the
|
|
@@ -17241,7 +17409,19 @@ export async function startTUI(opts) {
|
|
|
17241
17409
|
// now 7-12ms warm, so the notice did nothing except paint a frame, get replaced a moment later, and
|
|
17242
17410
|
// make the paste feel like it was waiting for something. The text appears, or an error is reported —
|
|
17243
17411
|
// both are visible without an intermediate state.
|
|
17244
|
-
const
|
|
17412
|
+
const read = await readClipboardContentAsync();
|
|
17413
|
+
let { text, via } = read;
|
|
17414
|
+
// A COPIED IMAGE WITH AN ADDRESS DOES NOT NEED A FILE.
|
|
17415
|
+
//
|
|
17416
|
+
// Chromium puts the pixels, an HTML fragment (`<img src=…>`) and — for a plain image — the image's own
|
|
17417
|
+
// URL on the clipboard at once. The reader answers with the file list or the saved PNG first, so a copied
|
|
17418
|
+
// web image was written to disk for content the model can simply fetch. The TEXT formats are the only ones
|
|
17419
|
+
// that carry an address, so they are consulted before the PNG is accepted.
|
|
17420
|
+
if (via === 'image') {
|
|
17421
|
+
const direct = await readClipboardTextOnly();
|
|
17422
|
+
const url = direct ? imageUrlFromClipboardText(direct) : null;
|
|
17423
|
+
if (url) { text = url; via = 'text'; }
|
|
17424
|
+
}
|
|
17245
17425
|
if (text) {
|
|
17246
17426
|
// An image collapses to a `[paste #N image]` chip, the same way a multi-line
|
|
17247
17427
|
// paste becomes `[paste #N +L lines]`, so the prompt does not fill up with a
|
|
@@ -17276,8 +17456,14 @@ export async function startTUI(opts) {
|
|
|
17276
17456
|
renderFrame();
|
|
17277
17457
|
return;
|
|
17278
17458
|
}
|
|
17459
|
+
// QUEUED ITEMS ARE ALREADY EXPANDED; THE DRAFT IS NOT.
|
|
17460
|
+
//
|
|
17461
|
+
// `submit` expands paste placeholders before it queues a message, so `state.queued` holds real text. The
|
|
17462
|
+
// draft still holds `[Pasted text #1 …]` markers — those are what the composer shows — and steering it
|
|
17463
|
+
// verbatim sent the marker to the model and printed it in the transcript. Same expansion the composer does.
|
|
17279
17464
|
const items = (state.queued || []).slice();
|
|
17280
|
-
const
|
|
17465
|
+
const rawDraft = String(state.input || '');
|
|
17466
|
+
const draft = rawDraft.trim() ? expandPastes(rawDraft, state.pastes).trim() : '';
|
|
17281
17467
|
if (draft) items.push(draft);
|
|
17282
17468
|
if (!items.length) {
|
|
17283
17469
|
notice('Nothing queued to steer', 'error');
|
|
@@ -17400,12 +17586,58 @@ export async function startTUI(opts) {
|
|
|
17400
17586
|
return keybindings;
|
|
17401
17587
|
}
|
|
17402
17588
|
|
|
17589
|
+
// WHICH KEYS ACCELERATE WHEN HELD. Anything that MOVES — the caret, the selection, the cursor — and
|
|
17590
|
+
// anything that DELETES one unit at a time. Enter, Escape, Tab and ordinary characters are deliberately
|
|
17591
|
+
// absent: firing those twice is a bug, not a convenience.
|
|
17592
|
+
const REPEATABLE_KEYS = new Set([
|
|
17593
|
+
'backspace', 'delete', 'left', 'right', 'up', 'down',
|
|
17594
|
+
'home', 'end', 'pageup', 'pagedown', 'c-left', 'c-right', 'c-up', 'c-down',
|
|
17595
|
+
]);
|
|
17596
|
+
/** A gap longer than this starts a fresh run, so two separate presses are never merged. */
|
|
17597
|
+
const REPEAT_WINDOW_MS = 140; // the same deliberate/burst boundary `wheelStep` uses
|
|
17598
|
+
/** The most one event may stand for. */
|
|
17599
|
+
const REPEAT_MAX = 8; // same ceiling as `wheelStep`
|
|
17600
|
+
const repeatState = { key: null, at: 0, run: 0 };
|
|
17601
|
+
|
|
17403
17602
|
function handleKey(t) {
|
|
17404
17603
|
// A KITTY GRAPHICS REPLY IS NOT A KEYSTROKE. It is the terminal acknowledging an upload (`G i=31;OK`) or
|
|
17405
17604
|
// reporting that it dropped an image. The token exists so the bytes are CONSUMED rather than typed into the
|
|
17406
17605
|
// composer; there is nothing to act on yet, because a failed upload is handled by placing the formula as
|
|
17407
17606
|
// text next time rather than by retrying mid-frame.
|
|
17408
17607
|
if (t && t.kittyReply !== undefined) return;
|
|
17608
|
+
// HOLDING A KEY REPEATS IT AT A FIXED RATE, WHICHEVER ROW IT LANDS ON.
|
|
17609
|
+
//
|
|
17610
|
+
// The wheel already accelerates: `wheelStep` widens the jump as notches arrive faster, so a fast scroll
|
|
17611
|
+
// moves eight rows where a deliberate one moves one. Every OTHER repeat — backspace, the arrows, home and
|
|
17612
|
+
// end, page keys — ran once per event no matter how fast the terminal auto-repeated, so holding backspace
|
|
17613
|
+
// through a long line crawled while holding the wheel flew. Both are "the same key, arriving fast".
|
|
17614
|
+
//
|
|
17615
|
+
// The run is folded into ONE call carrying `_repeat`, and the handlers that support it act that many
|
|
17616
|
+
// times. A key not in the list is untouched: Enter, Escape and ordinary characters must never fire twice.
|
|
17617
|
+
if (t && typeof t.key === 'string' && REPEATABLE_KEYS.has(t.key)) {
|
|
17618
|
+
const now = Date.now();
|
|
17619
|
+
const gap = now - (repeatState.at || 0);
|
|
17620
|
+
repeatState.at = now;
|
|
17621
|
+
repeatState.key = t.key;
|
|
17622
|
+
// THE BATCH COMES FROM THE GAP, NOT FROM HOW LONG THE KEY HAS BEEN HELD.
|
|
17623
|
+
//
|
|
17624
|
+
// Counting the run and batching on its length multiplied twice: a held key already arrives every ~40ms,
|
|
17625
|
+
// so growing the batch to eight made the last event stand for eight MORE deltas on top of the ~25Hz
|
|
17626
|
+
// stream — hundreds of characters a second, and the caret shot past where anyone wanted it. The gap
|
|
17627
|
+
// alone says how fast the terminal repeats, which is the thing to compensate for.
|
|
17628
|
+
//
|
|
17629
|
+
// 140ms+ a deliberate tap -> 1
|
|
17630
|
+
// 70-140 a brisk double tap -> 2
|
|
17631
|
+
// 35-70 the terminal repeating -> 3
|
|
17632
|
+
// under 35 a fast repeat -> 4
|
|
17633
|
+
t._repeat = gap >= REPEAT_WINDOW_MS ? 1
|
|
17634
|
+
: gap >= REPEAT_WINDOW_MS / 2 ? 2
|
|
17635
|
+
: gap >= REPEAT_WINDOW_MS / 4 ? 3
|
|
17636
|
+
: 4;
|
|
17637
|
+
} else {
|
|
17638
|
+
repeatState.key = null;
|
|
17639
|
+
repeatState.at = 0;
|
|
17640
|
+
}
|
|
17409
17641
|
// USER KEYBINDINGS, their own layer. A binding either REPLACES a key with another key
|
|
17410
17642
|
// (applied by rewriting `t.key` below, so the whole switch sees the bound key without
|
|
17411
17643
|
// knowing bindings exist) or runs a slash command. Mouse and wheel events are never
|
|
@@ -17502,7 +17734,7 @@ export async function startTUI(opts) {
|
|
|
17502
17734
|
return;
|
|
17503
17735
|
}
|
|
17504
17736
|
if (t.key === 'pageup' || t.key === 'pagedown') {
|
|
17505
|
-
scrollChat(state, t.key === 'pageup' ? 10 : -10);
|
|
17737
|
+
scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
|
|
17506
17738
|
renderFrame();
|
|
17507
17739
|
return;
|
|
17508
17740
|
}
|
|
@@ -17604,10 +17836,18 @@ export async function startTUI(opts) {
|
|
|
17604
17836
|
if (qs.editing === 'other') qs.otherText = qs.editingText || '';
|
|
17605
17837
|
else if (qs.editing === 'supplement') qs.supplement = qs.editingText || '';
|
|
17606
17838
|
qs.editing = null;
|
|
17839
|
+
// Tab is the OTHER way to leave a question, so it has to remember too — otherwise switching back with
|
|
17840
|
+
// Shift+Tab showed the first option again even though the previous answer was still recorded.
|
|
17841
|
+
const total0 = qs.items.length;
|
|
17842
|
+
if (qs.index < total0) {
|
|
17843
|
+
if (!qs.remember) qs.remember = new Map();
|
|
17844
|
+
qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
|
|
17845
|
+
}
|
|
17607
17846
|
const n = total + 1; // questions + the Other tab
|
|
17608
17847
|
qs.index = (qs.index + dir + n) % n;
|
|
17609
|
-
qs.
|
|
17610
|
-
qs.
|
|
17848
|
+
const back = qs.index < total0 && qs.remember ? qs.remember.get(qs.index) : null;
|
|
17849
|
+
qs.sel = back ? back.sel : 0;
|
|
17850
|
+
qs.picked = back ? new Set(back.picked) : new Set();
|
|
17611
17851
|
if (qs.index === total) {
|
|
17612
17852
|
// Landing on the Other tab opens its text field directly.
|
|
17613
17853
|
qs.editing = 'supplement';
|
|
@@ -17657,7 +17897,18 @@ export async function startTUI(opts) {
|
|
|
17657
17897
|
return;
|
|
17658
17898
|
}
|
|
17659
17899
|
if (!answer) { renderFrame(); return; } // nothing typed: stay
|
|
17660
|
-
|
|
17900
|
+
// A MULTI-SELECT KEEPS ITS OTHER PICKS. This overwrote `answers[cur.question]` with the typed text
|
|
17901
|
+
// alone, so a question where "Option A" and "Other: something" were both chosen came back with only
|
|
17902
|
+
// the typed half — the checkboxes were on screen but the answer that reached the model had lost them.
|
|
17903
|
+
if (cur.multiSelect) {
|
|
17904
|
+
const labels = [...qs.picked].sort((a, b) => a - b)
|
|
17905
|
+
.map((i) => (cur.options[i] ? cur.options[i].label : null))
|
|
17906
|
+
.filter(Boolean);
|
|
17907
|
+
labels.push(answer);
|
|
17908
|
+
qs.answers[cur.question] = labels.join(', ');
|
|
17909
|
+
} else {
|
|
17910
|
+
qs.answers[cur.question] = answer;
|
|
17911
|
+
}
|
|
17661
17912
|
qs.otherText = '';
|
|
17662
17913
|
qs.editing = null;
|
|
17663
17914
|
advance();
|
|
@@ -17707,7 +17958,13 @@ export async function startTUI(opts) {
|
|
|
17707
17958
|
if (t.key === 'enter') {
|
|
17708
17959
|
if (qs.sel >= otherIdx) { openEditor('other'); return; }
|
|
17709
17960
|
if (cur.multiSelect) {
|
|
17710
|
-
|
|
17961
|
+
// An option index past `cur.options` is the trailing "Other" row, whose text lives in `otherText`.
|
|
17962
|
+
// Dereferencing it blindly threw on a question where Other was among the checked rows.
|
|
17963
|
+
const labels = [...qs.picked].sort((a, b) => a - b)
|
|
17964
|
+
.map((i) => (cur.options[i] ? cur.options[i].label : null))
|
|
17965
|
+
.filter(Boolean);
|
|
17966
|
+
const typed = (qs.otherText || '').trim();
|
|
17967
|
+
if (typed) labels.push(typed);
|
|
17711
17968
|
if (labels.length) qs.answers[cur.question] = labels.join(', ');
|
|
17712
17969
|
advance();
|
|
17713
17970
|
return;
|
|
@@ -17725,7 +17982,7 @@ export async function startTUI(opts) {
|
|
|
17725
17982
|
return;
|
|
17726
17983
|
}
|
|
17727
17984
|
if (t.key === 'pageup' || t.key === 'pagedown') {
|
|
17728
|
-
scrollChat(state, t.key === 'pageup' ? 10 : -10);
|
|
17985
|
+
scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
|
|
17729
17986
|
renderFrame();
|
|
17730
17987
|
return;
|
|
17731
17988
|
}
|
|
@@ -18004,7 +18261,7 @@ export async function startTUI(opts) {
|
|
|
18004
18261
|
return;
|
|
18005
18262
|
}
|
|
18006
18263
|
if (t.key === 'pageup' || t.key === 'pagedown') {
|
|
18007
|
-
scrollChat(state, t.key === 'pageup' ? 10 : -10);
|
|
18264
|
+
scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
|
|
18008
18265
|
renderFrame(); return;
|
|
18009
18266
|
}
|
|
18010
18267
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
@@ -18057,7 +18314,11 @@ export async function startTUI(opts) {
|
|
|
18057
18314
|
}
|
|
18058
18315
|
// Draft is on its last/first row: the key is free. Scroll the transcript
|
|
18059
18316
|
// (useful while output streams and you want to read back).
|
|
18060
|
-
|
|
18317
|
+
//
|
|
18318
|
+
// `_repeat` makes a HELD arrow accelerate like the wheel does: three rows for a tap, up to
|
|
18319
|
+
// `REPEAT_MAX * 3` when the key is repeating. Without it the rows crawled while the wheel flew, even
|
|
18320
|
+
// though both arrive as the same fast stream of events.
|
|
18321
|
+
scrollChat(state, (t.key === 'up' ? 3 : -3) * Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1)));
|
|
18061
18322
|
renderFrame(); return;
|
|
18062
18323
|
}
|
|
18063
18324
|
}
|
|
@@ -18203,10 +18464,12 @@ export async function startTUI(opts) {
|
|
|
18203
18464
|
if (state.panel) {
|
|
18204
18465
|
const p = state.panel;
|
|
18205
18466
|
if (t.key === 'escape') { state.panel = null; state.hoverHit = null; renderFrame(); return; }
|
|
18206
|
-
|
|
18207
|
-
|
|
18208
|
-
if (t.key === '
|
|
18209
|
-
if (t.key === '
|
|
18467
|
+
// A held key moves the panel by `_repeat` rows, the same acceleration the transcript scroll uses.
|
|
18468
|
+
const rep = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
|
|
18469
|
+
if (t.key === 'up') { p.top = Math.max(0, (p.top || 0) - rep); renderFrame(); return; }
|
|
18470
|
+
if (t.key === 'down') { p.top = (p.top || 0) + rep; renderFrame(); return; }
|
|
18471
|
+
if (t.key === 'pageup') { p.top = Math.max(0, (p.top || 0) - 10 * rep); renderFrame(); return; }
|
|
18472
|
+
if (t.key === 'pagedown') { p.top = (p.top || 0) + 10 * rep; renderFrame(); return; }
|
|
18210
18473
|
if (t.key === 'home') { p.top = 0; renderFrame(); return; }
|
|
18211
18474
|
return;
|
|
18212
18475
|
}
|
|
@@ -18412,7 +18675,7 @@ export async function startTUI(opts) {
|
|
|
18412
18675
|
// ↑/↓ scroll the box's history, in ROWS. Held on the state as an offset from
|
|
18413
18676
|
// the tail, matching the transcript's convention.
|
|
18414
18677
|
if (t.key === 'up' || t.key === 'down') {
|
|
18415
|
-
const step = t.key === 'up' ? 4 : -4;
|
|
18678
|
+
const step = (t.key === 'up' ? 4 : -4) * Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
|
|
18416
18679
|
state.btwScroll = Math.max(0, (state.btwScroll || 0) + step);
|
|
18417
18680
|
renderFrame();
|
|
18418
18681
|
return;
|
|
@@ -18592,7 +18855,7 @@ export async function startTUI(opts) {
|
|
|
18592
18855
|
}
|
|
18593
18856
|
return;
|
|
18594
18857
|
}
|
|
18595
|
-
scrollChat(state, t.key === 'pageup' ? 10 : -10);
|
|
18858
|
+
scrollChat(state, t.key === 'pageup' ? 10 : -10); // a page is a big step already, so no _repeat scaling
|
|
18596
18859
|
renderFrame(); return;
|
|
18597
18860
|
}
|
|
18598
18861
|
// Shift+arrows EXTEND a composer selection instead of moving the caret. The
|
|
@@ -18637,13 +18900,24 @@ export async function startTUI(opts) {
|
|
|
18637
18900
|
|
|
18638
18901
|
if (t.key === 'left' || t.key === 'right') {
|
|
18639
18902
|
const dir = t.key === 'left' ? -1 : 1;
|
|
18640
|
-
|
|
18641
|
-
|
|
18642
|
-
|
|
18643
|
-
|
|
18644
|
-
|
|
18645
|
-
|
|
18646
|
-
|
|
18903
|
+
// A held arrow moves `_repeat` cells per event; a paste marker is skipped whole, so it consumes one
|
|
18904
|
+
// step of the run rather than being walked into character by character.
|
|
18905
|
+
const times = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
|
|
18906
|
+
for (let nth = 0; nth < times; nth++) {
|
|
18907
|
+
const mk = adjacentPasteMarker(state.input, state.caret || 0, dir);
|
|
18908
|
+
if (mk) {
|
|
18909
|
+
state.caret = dir < 0 ? mk.start : mk.end;
|
|
18910
|
+
} else if (dir < 0) {
|
|
18911
|
+
const next = Math.max(0, (state.caret || 0) - 1);
|
|
18912
|
+
const moved = next !== (state.caret || 0);
|
|
18913
|
+
state.caret = next;
|
|
18914
|
+
if (!moved) break;
|
|
18915
|
+
} else {
|
|
18916
|
+
const next = Math.min(state.input.length, (state.caret || 0) + 1);
|
|
18917
|
+
const moved = next !== (state.caret || 0);
|
|
18918
|
+
state.caret = next;
|
|
18919
|
+
if (!moved) break;
|
|
18920
|
+
}
|
|
18647
18921
|
}
|
|
18648
18922
|
// A horizontal move abandons the sticky column (kimi's setCursorCol).
|
|
18649
18923
|
state._caretPrefCol = null;
|
|
@@ -18730,21 +19004,29 @@ export async function startTUI(opts) {
|
|
|
18730
19004
|
renderFrame();
|
|
18731
19005
|
return;
|
|
18732
19006
|
}
|
|
18733
|
-
|
|
18734
|
-
|
|
18735
|
-
|
|
18736
|
-
|
|
18737
|
-
|
|
18738
|
-
|
|
18739
|
-
|
|
18740
|
-
|
|
18741
|
-
|
|
18742
|
-
state.input = state.input.slice(0,
|
|
18743
|
-
state.caret =
|
|
18744
|
-
|
|
19007
|
+
// `_repeat` is how many times the held key stands for — see the note in `handleKey`. A held backspace
|
|
19008
|
+
// then erases `_repeat` characters per event instead of one, which is what makes it keep up with the
|
|
19009
|
+
// wheel's acceleration.
|
|
19010
|
+
const times = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
|
|
19011
|
+
for (let nth = 0; nth < times; nth++) {
|
|
19012
|
+
const sel = state.composerSel;
|
|
19013
|
+
if (sel && sel.anchor !== sel.head) {
|
|
19014
|
+
const a = Math.min(sel.anchor, sel.head);
|
|
19015
|
+
const h = Math.max(sel.anchor, sel.head);
|
|
19016
|
+
state.input = state.input.slice(0, a) + state.input.slice(h);
|
|
19017
|
+
state.caret = a;
|
|
19018
|
+
} else if (state.caret > 0) {
|
|
19019
|
+
const mk = adjacentPasteMarker(state.input, state.caret, -1);
|
|
19020
|
+
if (mk) {
|
|
19021
|
+
state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
|
|
19022
|
+
state.caret = mk.start;
|
|
19023
|
+
state.pastes.delete(mk.id);
|
|
19024
|
+
} else {
|
|
19025
|
+
state.input = state.input.slice(0, state.caret - 1) + state.input.slice(state.caret);
|
|
19026
|
+
state.caret--;
|
|
19027
|
+
}
|
|
18745
19028
|
} else {
|
|
18746
|
-
|
|
18747
|
-
state.caret--;
|
|
19029
|
+
break; // nothing left to delete; the remaining repeats would be no-ops
|
|
18748
19030
|
}
|
|
18749
19031
|
}
|
|
18750
19032
|
// Deleting can also change the mention (or delete the `@` itself), so the
|
|
@@ -18754,19 +19036,25 @@ export async function startTUI(opts) {
|
|
|
18754
19036
|
renderFrame(); state.composerSel = null; return;
|
|
18755
19037
|
}
|
|
18756
19038
|
if (t.key === 'delete') {
|
|
18757
|
-
|
|
18758
|
-
|
|
18759
|
-
|
|
18760
|
-
const
|
|
18761
|
-
|
|
18762
|
-
|
|
18763
|
-
|
|
18764
|
-
|
|
18765
|
-
|
|
18766
|
-
|
|
18767
|
-
state.
|
|
18768
|
-
|
|
18769
|
-
|
|
19039
|
+
// `_repeat` batches a held key, the same way backspace does.
|
|
19040
|
+
const times = Math.max(1, Math.min(REPEAT_MAX, t._repeat || 1));
|
|
19041
|
+
for (let nth = 0; nth < times; nth++) {
|
|
19042
|
+
const sel = state.composerSel;
|
|
19043
|
+
if (sel && sel.anchor !== sel.head) {
|
|
19044
|
+
const a = Math.min(sel.anchor, sel.head);
|
|
19045
|
+
const h = Math.max(sel.anchor, sel.head);
|
|
19046
|
+
state.input = state.input.slice(0, a) + state.input.slice(h);
|
|
19047
|
+
state.caret = a;
|
|
19048
|
+
} else {
|
|
19049
|
+
const mk = adjacentPasteMarker(state.input, state.caret, 1);
|
|
19050
|
+
if (mk) {
|
|
19051
|
+
state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
|
|
19052
|
+
state.pastes.delete(mk.id);
|
|
19053
|
+
} else if (state.caret < state.input.length) {
|
|
19054
|
+
state.input = state.input.slice(0, state.caret) + state.input.slice(state.caret + 1);
|
|
19055
|
+
} else {
|
|
19056
|
+
break; // at the end of the line: the remaining repeats would be no-ops
|
|
19057
|
+
}
|
|
18770
19058
|
}
|
|
18771
19059
|
}
|
|
18772
19060
|
refreshMenu(state); renderFrame(); state.composerSel = null; return;
|
|
@@ -18957,6 +19245,9 @@ function wheelStep(state, family) {
|
|
|
18957
19245
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
18958
19246
|
state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
|
|
18959
19247
|
state.mentionOffset = 0;
|
|
19248
|
+
// A NEW TURN means a new chance for the goal to restart it. The counter exists so one turn restarts the
|
|
19249
|
+
// goal at most once; without resetting it here, the second turn of a goal would never continue.
|
|
19250
|
+
state._goalTurns = 0;
|
|
18960
19251
|
state.historyIdx = -1;
|
|
18961
19252
|
// The draft belongs to the history walk that just ended; the next ↑ starts fresh.
|
|
18962
19253
|
state.draftInput = '';
|