@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/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 `count * stepMs`
796
- * of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
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
- const steps = Math.floor((phaseMs / crossMs(n, stepMs)) * travel);
931
- const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
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, sweepWidth) {
959
- // THE SWEEP PACE IS SET BY THE ROW, NOT BY THE WORD.
960
- //
961
- // A tool name is four characters (`Read`) in a row of eighty. Sweeping the four made the light cross in a
962
- // few frames — each character changed colour inside one frame, which reads as a SNAP, and the harder you
963
- // look the less it looks like motion. The fix is not to sweep the empty cells instead (they are never
964
- // painted, so the light would be invisible for most of its travel and the word would sit dark); it is to
965
- // run the SAME sweep geometry over a wider notional row and place the glyphs inside it, which slows the
966
- // light down without moving where it appears.
967
- //
968
- // `lightAt` measures its geometry in cells, so the pace comes from `count`. Passing the row's width makes
969
- // the crossing take proportionally longer; the glyphs stay at their own indices, and the light still visits
970
- // every one of them, just over more frames.
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, pace, edge, stepMs)));
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
- export const SWEEP_TICKS = 10; // ticks per half-sweep (0.8s, so a 3.2s cycle)
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
- // The sweep's geometry is measured over the ROW, so its cycle has to be measured over the row too.
2207
- // `crossMs(name.length)` describes a crossing of four cells; the phase would wrap long before a row-wide
2208
- // light had moved a fifth of the way, and the name would sit at one brightness forever.
2209
- const spentW = visualCol('● ') + visualCol(verb) + 1;
2210
- const sweepW = width > 0 ? Math.max(name.length, width - spentW) : name.length;
2211
- const phaseMs = elapsedMs % cycleMs(sweepW, TOOL_STEP_MS);
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, sweepW);
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: clipping it to make room for the scrollbar truncates the
6026
- // sequence. The picture is placed by the frame writer, so it needs the row verbatim.
6027
- row = isImageRow(row) ? row : fitAnsi(row, w - 1) + fg + bg + glyphChar + C.reset;
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
- tabs += on ? col(' ' + name + ' ', C.selBg + C.cyan + C.bold) : col(' ' + name + ' ', C.gray);
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
- itemRows.forEach((r) => addHit(boxTop + r.boxIdx, 0, w - 1, { kind: 'questionItem', index: r.index }));
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
- // The cycle is measured over the ROW, matching the geometry `shimmerText` is given below. A cycle sized
6601
- // to the phrase would wrap while a row-wide light had barely moved, and the phrase would hold one
6602
- // brightness for the whole turn.
6603
- const workSweep = Math.max(workMsg.length, cols - 2);
6604
- const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workSweep);
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
- // THE LIGHT CROSSES THE ROW, NOT THE PHRASE.
6621
- //
6622
- // `Working...` is ten characters in a row that has eighty columns. Sweeping only the ten made the light
6623
- // move one glyph per frame — a snap rather than a fade. The sweep now covers the room the row actually
6624
- // has, so the same phrase animates at a speed the eye reads as motion. Only the phrase's own cells are
6625
- // painted; a coloured space would smear the row's padding.
6626
- const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge, STEP_MS, Math.max(chars.length, cols - 2));
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
- // The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
6648
- // the mouse handler can hover/press it; the row offset is corrected to final
6649
- // screen coordinates by composeFrame's hitbox pass (like every other hit).
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
- addHit(todoTopRow, 0, w - 1, { kind: 'todoResize' });
6652
- for (const l of renderTodoPanel(state, w, state.todoResizeHover, state.todoResizeDrag)) lines.push(l);
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
- // The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
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
- addHit(queueTopRow, 0, w - 1, { kind: 'queueResize' });
6662
- for (const l of renderQueuePanel(state, w, state.queueResizeHover, state.queueResizeDrag)) lines.push(l);
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
- ctxPercent: 0,
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: false,
7653
- ctxTokens: 0,
7654
- ctxMax: cfg.maxContextTokens || 512000,
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 SAME FORMULA AS A LIVE STEP: messages PLUS the tool schemas. This used to count the
9168
- // messages alone, so the gauge stepped by a fixed amount whenever a restore and a live step
9169
- // alternated — a visible jump that was two formulas disagreeing, not noise.
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
- // `dispatch` is module-level and cannot reach startTUI's `setCtxTokens`, so the expression is
9172
- // repeated here rather than the call. Keep the two identical.
9173
- const approx = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
9174
- // A resumed session carries the last REPORTED size, so the gauge starts where it left off
9175
- // instead of at the estimate.
9176
- if (!state.ctxReportedOnce) state.ctxTokens = approx;
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
- state.ctxPercent = usagePercent(approx, state.ctxMax);
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
- ...state.chat.filter((m) => !m._conv),
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
- state.ctxPercent = usagePercent(state.tokens, state.ctxMax || maxCtx);
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.goalPaused = true; app('Goal paused. Use /goal resume to continue.'); return; }
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.goalPaused = false; app('Goal resumed.');
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') { state.objective = ''; state.goalPaused = false; app('Goal cancelled.'); return; }
9978
- if (a.startsWith('replace ')) { state.objective = raw.slice(8).trim(); app('Goal replaced.'); return; }
9979
- state.objective = raw; state.goalPaused = false;
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
- state.ctxPercent = usagePercent(approx, state.ctxMax);
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.sel = 0;
16561
- qs.picked = new Set();
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 { text, via } = await readClipboardContentAsync();
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 draft = String(state.input || '').trim();
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.sel = 0;
17610
- qs.picked = new Set();
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
- qs.answers[cur.question] = answer;
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
- const labels = [...qs.picked].sort((a, b) => a - b).map((i) => cur.options[i].label);
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
- scrollChat(state, t.key === 'up' ? 3 : -3);
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
- if (t.key === 'up') { p.top = Math.max(0, (p.top || 0) - 1); renderFrame(); return; }
18207
- if (t.key === 'down') { p.top = (p.top || 0) + 1; renderFrame(); return; }
18208
- if (t.key === 'pageup') { p.top = Math.max(0, (p.top || 0) - 10); renderFrame(); return; }
18209
- if (t.key === 'pagedown') { p.top = (p.top || 0) + 10; renderFrame(); return; }
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
- const mk = adjacentPasteMarker(state.input, state.caret || 0, dir);
18641
- if (mk) {
18642
- state.caret = dir < 0 ? mk.start : mk.end;
18643
- } else if (dir < 0) {
18644
- state.caret = Math.max(0, (state.caret || 0) - 1);
18645
- } else {
18646
- state.caret = Math.min(state.input.length, (state.caret || 0) + 1);
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
- const sel = state.composerSel;
18734
- if (sel && sel.anchor !== sel.head) {
18735
- const a = Math.min(sel.anchor, sel.head);
18736
- const h = Math.max(sel.anchor, sel.head);
18737
- state.input = state.input.slice(0, a) + state.input.slice(h);
18738
- state.caret = a;
18739
- } else if (state.caret > 0) {
18740
- const mk = adjacentPasteMarker(state.input, state.caret, -1);
18741
- if (mk) {
18742
- state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
18743
- state.caret = mk.start;
18744
- state.pastes.delete(mk.id);
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
- state.input = state.input.slice(0, state.caret - 1) + state.input.slice(state.caret);
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
- const sel = state.composerSel;
18758
- if (sel && sel.anchor !== sel.head) {
18759
- const a = Math.min(sel.anchor, sel.head);
18760
- const h = Math.max(sel.anchor, sel.head);
18761
- state.input = state.input.slice(0, a) + state.input.slice(h);
18762
- state.caret = a;
18763
- } else {
18764
- const mk = adjacentPasteMarker(state.input, state.caret, 1);
18765
- if (mk) {
18766
- state.input = state.input.slice(0, mk.start) + state.input.slice(mk.end);
18767
- state.pastes.delete(mk.id);
18768
- } else if (state.caret < state.input.length) {
18769
- state.input = state.input.slice(0, state.caret) + state.input.slice(state.caret + 1);
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 = '';