@hncode/hncode 0.6.2 → 0.6.4

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
@@ -23,7 +23,6 @@ import {
23
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
- // The picker is composited onto the frame as a cell layer rather than assigned over
27
26
  // it (see src/cell-buffer.js), so the transcript BESIDE the card survives.
28
27
  import { parseCells, renderCells, drawFrameBuffer } from './cell-buffer.js';
29
28
  // Streaming text is paced through a backlog so a coalescing provider (Anthropic) looks
@@ -189,6 +188,7 @@ export const COMMANDS = [
189
188
  { name: 'settings', aliases: ['config'], desc: 'Browse and change every setting, grouped by area', priority: 100, argumentHint: '[tab|filter]' },
190
189
  { name: 'set', desc: 'Set one config value by key, or list what differs from the default', priority: 100, argumentHint: '[<key> [value]]' },
191
190
  { name: 'plan', desc: 'Toggle plan mode', priority: 100, argumentHint: '[on|off|clear]' },
191
+ { name: 'websearch-api', desc: 'Edit the WebSearch API keys (one per line, tried in order)', priority: 96 },
192
192
  { name: 'focus', desc: 'Toggle Focus mode (minimal tools first, full tools after)', priority: 98, argumentHint: '[on|off]' },
193
193
  { name: 'auto', desc: 'Auto mode: never interrupts you; everything runs and is decided automatically.', priority: 99 },
194
194
  { name: 'ask', aliases: ['manual'], desc: 'Ask mode: read-only runs automatically; every other action asks first.', priority: 99 },
@@ -1529,16 +1529,70 @@ export function editDiffRows(diff) {
1529
1529
  const before = Array.isArray(ctx.before) ? ctx.before : [];
1530
1530
  const after = Array.isArray(ctx.after) ? ctx.after : [];
1531
1531
  const startLine = Number(diff.startLine) > 0 ? Number(diff.startLine) : 1;
1532
+
1533
+ // AN EDIT THAT CHANGED ONE LINE SHOWS ONE LINE CHANGED.
1534
+ //
1535
+ // This used to emit every old line as `del` and every new line as `add`, so replacing three lines whose last
1536
+ // one changed produced three deletions and three additions — the two identical lines scrolled past as noise
1537
+ // and the one real change was buried. Aligning the two sides and emitting the unchanged pairs as CONTEXT is
1538
+ // what a diff is.
1539
+ const oldLines = split(diff.old);
1540
+ const newLines = split(diff.new);
1541
+
1532
1542
  const out = [];
1533
1543
  for (const c of before) out.push({ type: 'ctx', text: String(c.text), no: c.n });
1534
- let n = startLine;
1535
- for (const l of split(diff.old)) out.push({ type: 'del', text: l, no: n++ });
1536
- n = startLine;
1537
- for (const l of split(diff.new)) out.push({ type: 'add', text: l, no: n++ });
1544
+ let oldNo = startLine;
1545
+ let newNo = startLine;
1546
+ for (const step of alignLines(oldLines, newLines)) {
1547
+ if (step.op === 'same') { out.push({ type: 'ctx', text: oldLines[step.a], no: newNo }); oldNo++; newNo++; }
1548
+ else if (step.op === 'del') { out.push({ type: 'del', text: oldLines[step.a], no: oldNo++ }); }
1549
+ else { out.push({ type: 'add', text: newLines[step.b], no: newNo++ }); }
1550
+ }
1538
1551
  for (const c of after) out.push({ type: 'ctx', text: String(c.text), no: c.n });
1539
1552
  return elideContext(out);
1540
1553
  }
1541
1554
 
1555
+ /**
1556
+ * Align two line arrays into `same` / `del` / `add` steps (longest common subsequence).
1557
+ *
1558
+ * The table is O(n*m), which is right for the block an edit replaces — a handful of lines — and wrong for a
1559
+ * whole file. Past LCS_MAX_LINES on either side it falls back to "everything replaced", which is exactly what
1560
+ * the old code always did, so a pathological case is no worse than before and the common one is much better.
1561
+ *
1562
+ * @param {string[]} a Old lines.
1563
+ * @param {string[]} b New lines.
1564
+ * @returns {Array<{op:'same'|'del'|'add', a:number, b:number}>}
1565
+ */
1566
+ function alignLines(a, b) {
1567
+ const n = a.length;
1568
+ const m = b.length;
1569
+ const MAX = 400;
1570
+ const all = () => [
1571
+ ...a.map((_, i) => ({ op: 'del', a: i, b: 0 })),
1572
+ ...b.map((_, j) => ({ op: 'add', a: 0, b: j })),
1573
+ ];
1574
+ if (n * m > MAX * MAX) return all();
1575
+
1576
+ // `dp[i][j]` is the LCS length of a[i..] against b[j..], filled from the bottom right.
1577
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
1578
+ for (let i = n - 1; i >= 0; i--) {
1579
+ for (let j = m - 1; j >= 0; j--) {
1580
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
1581
+ }
1582
+ }
1583
+ const steps = [];
1584
+ let i = 0;
1585
+ let j = 0;
1586
+ while (i < n && j < m) {
1587
+ if (a[i] === b[j]) { steps.push({ op: 'same', a: i, b: j }); i++; j++; }
1588
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { steps.push({ op: 'del', a: i, b: j }); i++; }
1589
+ else { steps.push({ op: 'add', a: i, b: j }); j++; }
1590
+ }
1591
+ while (i < n) { steps.push({ op: 'del', a: i, b: j }); i++; }
1592
+ while (j < m) { steps.push({ op: 'add', a: i, b: j }); j++; }
1593
+ return steps;
1594
+ }
1595
+
1542
1596
  /**
1543
1597
  * Drop context rows further than DIFF_CONTEXT_LINES from any change, one `gap` per run.
1544
1598
  *
@@ -3337,29 +3391,58 @@ function renderTable(out, header, body, colCount, width, fg) {
3337
3391
  if (totalNatural <= availInner) {
3338
3392
  widths = natural;
3339
3393
  } else {
3340
- // Scale down proportionally, but never below 1.
3394
+ // Scale down proportionally, but never below MIN_COL. A column squeezed to 1 is a column whose content is
3395
+ // unreadable, so the shrink stops at a width a word can still use and the ROW grows taller instead.
3396
+ const MIN_COL = 4;
3341
3397
  const over = totalNatural - availInner;
3342
- const surplus = natural.reduce((a, b) => a + (b > 1 ? b - 1 : 0), 0) + cols * 1;
3398
+ const surplus = natural.reduce((a, b) => a + (b > MIN_COL ? b - MIN_COL : 0), 0);
3343
3399
  widths = natural.map((n) => {
3344
- const shrink = over > 0 ? Math.min(n - 1, Math.round((n - 1) * (over / Math.max(1, surplus)))) : 0;
3345
- return Math.max(1, n - shrink);
3400
+ const shrink = over > 0 && surplus > 0 ? Math.round((Math.max(0, n - MIN_COL)) * (over / surplus)) : 0;
3401
+ return Math.max(Math.min(MIN_COL, n), n - shrink);
3346
3402
  });
3347
3403
  }
3348
- // Render one row; bold for the header.
3349
- const oneRow = (cells, bold, onlyCells) => {
3350
- const parts = [];
3351
- for (let c = 0; c < cols; c++) {
3352
- const v = val(cells, c);
3353
- const cell = ' ' + v + ' '.repeat(Math.max(0, widths[c] - visualCol(v))) + ' ';
3354
- parts.push(bold ? col(cell, C.white + C.bold) : col(cell, C.gray));
3404
+ // WRAP, DO NOT TRUNCATE. A cell whose text does not fit its column used to be emitted at full length, which
3405
+ // pushed every border after it to the right and broke the table's alignment for the rest of the row. The
3406
+ // row now grows taller: each cell is wrapped to its column, and every line is padded to the same width.
3407
+ const wrapCell = (text, w) => {
3408
+ if (!text) return [''];
3409
+ return wrapWords(text, Math.max(1, w));
3410
+ };
3411
+ // A cell renders its INLINE MARKDOWN (bold, code, links) — a table of `**name**` and `\`value\`` used to show
3412
+ // the asterisks and backticks verbatim, which is not what the author wrote.
3413
+ const styled = (text, w, bold) => {
3414
+ const plain = wrapCell(text, w);
3415
+ const paint = bold ? C.white + C.bold : C.gray;
3416
+ return plain.map((line) => {
3417
+ // Render the markdown FIRST, then pad the RESULT.
3418
+ //
3419
+ // The pad has to be measured on what the cell will actually PRINT, not on the source: `` `--verbose` ``
3420
+ // is 11 characters of source but 9 on screen once the backticks become a colour, so padding by the source
3421
+ // width left the column short by two and every border after it moved left.
3422
+ const md = inlineMarkdown(line, C.gray, bold ? C.white : C.gray);
3423
+ const printed = visualCol(String(md).replace(/\x1b\[[0-9;]*m/g, ''));
3424
+ const pad = Math.max(0, w - printed);
3425
+ return col(' ', paint) + col(md, paint) + col(' '.repeat(pad) + ' ', paint);
3426
+ });
3427
+ };
3428
+ const rule = (left, mid, right) => col(left + widths.map((w) => '─'.repeat(w + 2)).join(mid) + right, B);
3429
+ // One logical row, as several physical lines when any cell wraps.
3430
+ const oneRow = (cells, bold) => {
3431
+ const rendered = [];
3432
+ for (let c = 0; c < cols; c++) rendered.push(styled(val(cells, c), widths[c], bold));
3433
+ const height = Math.max(1, ...rendered.map((r) => r.length));
3434
+ for (let line = 0; line < height; line++) {
3435
+ const parts = rendered.map((r, c) => (r[line] !== undefined
3436
+ ? r[line]
3437
+ : col(' ' + ' '.repeat(widths[c]) + ' ', bold ? C.white + C.bold : C.gray)));
3438
+ out.push(col('│', B) + parts.join(col('│', B)) + col('│', B));
3355
3439
  }
3356
- out.push(col('│', B) + parts.join(col('│', B)) + col('│', B));
3357
3440
  };
3358
- out.push(col('╭' + widths.map((w) => '─'.repeat(w + 2)).join('┬') + '╮', B));
3441
+ out.push(rule('╭', '┬', '╮'));
3359
3442
  oneRow(header, true);
3360
- out.push(col('├' + widths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤', B));
3443
+ out.push(rule('├', '┼', '┤'));
3361
3444
  for (const r of body) oneRow(r, false);
3362
- out.push(col('╰' + widths.map((w) => '─'.repeat(w + 2)).join('┴') + '╯', B));
3445
+ out.push(rule('╰', '┴', '╯'));
3363
3446
  }
3364
3447
  function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
3365
3448
  const out = [];
@@ -3928,6 +4011,7 @@ export function renderChatLines(state, w, viewport) {
3928
4011
  let prevBordered = false;
3929
4012
  let rowStart;
3930
4013
  let msgLen;
4014
+ let sigs;
3931
4015
  let cursor = 0;
3932
4016
  // Reuse the previous paint's metrics when the transcript and layout are
3933
4017
  // unchanged. Scrolling changes NEITHER, so a scroll frame can skip the O(n)
@@ -3941,16 +4025,46 @@ export function renderChatLines(state, w, viewport) {
3941
4025
  msgLen = mem.msgLen;
3942
4026
  cursor = mem.total;
3943
4027
  prevBordered = mem.prevBordered;
4028
+ sigs = mem.sigs;
3944
4029
  } else {
4030
+ // A HEAD START FROM THE MESSAGES THAT DID NOT CHANGE.
4031
+ //
4032
+ // Streaming rewrites only the last message, but that changes the transcript signature, so this loop used to
4033
+ // re-validate every row: measured at 16-18ms on a 2000-row transcript, which is the whole 60fps budget for
4034
+ // one streamed chunk. The signatures below are per message, so the run of unchanged ones at the FRONT can be
4035
+ // copied rather than recomputed — their row counts depend on their own content and on the border flag of the
4036
+ // message before them, both of which are already known.
4037
+ //
4038
+ // Bounded: a session with a message edited near the top falls back to the full walk, exactly as before.
4039
+ const prevSigs = (mem && mem.w === w && mem.expanded === expanded && mem.innerW === innerW
4040
+ && mem.sigs && mem.sigs.length <= n) ? mem.sigs : null;
4041
+ let reuse = 0;
4042
+ if (prevSigs && mem.rowStart && mem.msgLen) {
4043
+ while (reuse < prevSigs.length && reuse < n
4044
+ && prevSigs[reuse] === messageSignature(chat[reuse])) reuse++;
4045
+ }
4046
+ // NOT `const`: `sigs` was declared beside `rowStart` so the unchanged-transcript branch can carry it
4047
+ // forward. A local declaration here shadowed it, so the array was built and then thrown away — the next
4048
+ // frame found `mem.sigs === undefined` and fell back to a full walk, which is exactly what this was for.
4049
+ sigs = new Uint32Array(n);
3945
4050
  rowStart = new Int32Array(n);
3946
4051
  msgLen = new Int32Array(n);
3947
- for (let mi = 0; mi < n; mi++) {
4052
+ if (reuse > 0) {
4053
+ rowStart.set(mem.rowStart.subarray(0, reuse), 0);
4054
+ msgLen.set(mem.msgLen.subarray(0, reuse), 0);
4055
+ sigs.set(prevSigs.subarray(0, reuse), 0);
4056
+ cursor = rowStart[reuse - 1] + msgLen[reuse - 1];
4057
+ prevBordered = chat[reuse - 1].role === 'user' || chat[reuse - 1].role === 'queued'
4058
+ || chat[reuse - 1].role === 'bash';
4059
+ }
4060
+ for (let mi = reuse; mi < n; mi++) {
3948
4061
  const m = chat[mi];
3949
4062
  const border = m.role === 'user' || m.role === 'queued' || m.role === 'bash';
3950
4063
  const ww = border ? Math.max(1, Math.min(1, innerW - 4)) : innerW; // placeholder
3951
4064
  void ww;
3952
4065
  rowStart[mi] = cursor;
3953
4066
  const rules = border ? (prevBordered ? 1 : 2) : 0;
4067
+ sigs[mi] = messageSignature(m);
3954
4068
  // Reuse/minimal layout for the row count: same width/border arithmetic as
3955
4069
  // the layout path below. We need boxText for bordered messages.
3956
4070
  const boxInner = Math.max(1, innerW - 4);
@@ -4105,6 +4219,9 @@ export function renderChatLines(state, w, viewport) {
4105
4219
  // every wheel tick stall on a long transcript.
4106
4220
  state._metrics = {
4107
4221
  sig: sig || metricsSignature(state, n, w, expanded, innerW),
4222
+ // The per-message signatures, so the NEXT frame can find the first message that changed instead of
4223
+ // re-validating every row. See the reuse block above pass 1.
4224
+ sigs,
4108
4225
  w, expanded, innerW, total, rowStart, msgLen, prevBordered,
4109
4226
  };
4110
4227
 
@@ -4253,6 +4370,29 @@ export function renderChatLines(state, w, viewport) {
4253
4370
  return out;
4254
4371
  }
4255
4372
 
4373
+ /**
4374
+ * One message's contribution to the layout signature.
4375
+ *
4376
+ * Split out from `metricsSignature` so pass 1 can compare messages one by one and skip the unchanged ones:
4377
+ * streaming rewrites only the LAST message, yet the whole-transcript signature changes, and re-validating
4378
+ * every row cost 16-18ms on a 2000-row transcript — the entire frame budget for a single streamed chunk.
4379
+ *
4380
+ * @param {object} m
4381
+ * @returns {number}
4382
+ */
4383
+ export function messageSignature(m) {
4384
+ let h = 2166136261 >>> 0;
4385
+ const mix = (v) => { h ^= (v | 0); h = Math.imul(h, 16777619) >>> 0; };
4386
+ mix(typeof m.text === 'string' ? m.text.length : 0);
4387
+ mix(typeof m.streamContent === 'string' ? m.streamContent.length : 0);
4388
+ mix(m.pending ? 1 : 0);
4389
+ mix(m.pluginLog && m.expanded ? 1 : 0);
4390
+ mix(m._cache ? 1 : 0);
4391
+ mix(m._count ? 1 : 0);
4392
+ if (m.pending) mix(Math.floor(Date.now() / STEP_MS));
4393
+ return h >>> 0;
4394
+ }
4395
+
4256
4396
  // A cheap signature of everything pass 1 depends on. Values are only compared
4257
4397
  // (never stringified per message), so this stays O(n) arithmetic with no
4258
4398
  // allocation per message — the same reason countKey is numeric.
@@ -5378,7 +5518,6 @@ export function contextBreakdown(session, cfg) {
5378
5518
  const byRole = { system: 0, user: 0, assistant: 0, tool: 0, other: 0 };
5379
5519
  const countByRole = { system: 0, user: 0, assistant: 0, tool: 0, other: 0 };
5380
5520
  for (const m of ((session && session.messages) || [])) {
5381
- // Per-message estimator, memoised: `estimateMessagesTokens([m])` re-ran the whole message
5382
5521
  // Per-message estimator, memoised: `estimateMessagesTokens([m])` re-ran the whole message
5383
5522
  // loop for a single-element array, which on a long transcript made /context O(n²). The
5384
5523
  // memo means re-opening /context — or opening it after the bar already priced these
@@ -5462,6 +5601,47 @@ export function contextSegments(state, cfg) {
5462
5601
  // chat (fills the top) / composer box / [command menu] / status line /
5463
5602
  // [Ctrl-C confirm] / context line (very last screen row).
5464
5603
  // Returns { ansi, cursor: {row, col} } with 0-based coords.
5604
+ /**
5605
+ * The selected COLUMN RANGE on text line `idx`, or null when that line has none.
5606
+ *
5607
+ * MODULE LEVEL ON PURPOSE: `composeFrame` draws the editor and is not inside `startTUI`, so a helper
5608
+ * declared there would be invisible to it. A drag is stored as two `{ row, col }` points, which the
5609
+ * renderer cannot use directly — a selection can begin on one line and end on another, so each line needs
5610
+ * its own span. The FIRST and LAST lines are partial and every line between them is selected whole.
5611
+ *
5612
+ * @param {object} ed The editor state (`text`, `sel`).
5613
+ * @param {number} idx Text-line index.
5614
+ * @returns {{from: number, to: number}|null}
5615
+ */
5616
+ function editorSelectionOnLine(ed, idx) {
5617
+
5618
+ const sel = ed && ed.sel;
5619
+
5620
+ if (!sel || !sel.anchor || !sel.head) return null;
5621
+
5622
+ const a = sel.anchor;
5623
+
5624
+ const h = sel.head;
5625
+
5626
+ // Normalise so `top` is the earlier line regardless of which way the drag went.
5627
+
5628
+ const top = a.row <= h.row ? a : h;
5629
+
5630
+ const bot = a.row <= h.row ? h : a;
5631
+
5632
+ if (idx < top.row || idx > bot.row) return null;
5633
+
5634
+ const lineLen = String((ed.text.split("\n")[idx]) || "").length;
5635
+
5636
+ const from = idx === top.row ? top.col : 0;
5637
+
5638
+ const to = idx === bot.row ? bot.col : lineLen;
5639
+
5640
+ return { from: Math.min(from, to), to: Math.max(from, to) };
5641
+
5642
+ }
5643
+
5644
+
5465
5645
  export function composeFrame(state, cols, rows) {
5466
5646
  const w = Math.max(20, cols | 0);
5467
5647
  const h = Math.max(12, rows | 0);
@@ -5664,7 +5844,26 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5664
5844
  for (let i = 0; i < viewH; i++) {
5665
5845
  const idx = top + i;
5666
5846
  const txt = idx < textLines.length ? expandTabs(textLines[idx]) : '';
5667
- lines.push(col(fitAnsi(txt, w), C.white));
5847
+ // HIGHLIGHT THE SELECTED COLUMNS OF THIS LINE.
5848
+ //
5849
+ // A drag in this editor is tracked in TEXT coordinates (`ed.sel`), so a line is drawn in up to three
5850
+ // pieces: before the selection, the selection itself, and after it. Painting the whole row one colour and
5851
+ // the selection another is not enough — the selection can start and end mid-line.
5852
+ const span = editorSelectionOnLine(ed, idx);
5853
+ if (span && span.to > span.from) {
5854
+ const before = txt.slice(0, span.from);
5855
+ const mid = txt.slice(span.from, span.to);
5856
+ const after = txt.slice(span.to);
5857
+ const fitted = fitAnsi(before + mid + after, w);
5858
+ // Re-split after fitting: `fitAnsi` may have cut the row, so the boundaries are re-derived from the
5859
+ // fitted text rather than assumed.
5860
+ const b = fitted.slice(0, Math.min(before.length, fitted.length));
5861
+ const m = fitted.slice(b.length, Math.min(before.length + mid.length, fitted.length));
5862
+ const a = fitted.slice(b.length + m.length);
5863
+ lines.push(col(b, C.white) + col(m, C.selBg) + col(a, C.white));
5864
+ } else {
5865
+ lines.push(col(fitAnsi(txt, w), C.white));
5866
+ }
5668
5867
  // Mouse click on this body row moves the caret to that line. The payload is
5669
5868
  // `line`, NOT `row`: `row` is the hitbox's SCREEN row and composeFrame
5670
5869
  // overwrites it with the topPad/topTrim correction, which turned the text
@@ -5983,11 +6182,17 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5983
6182
  }
5984
6183
  // EVERY TRANSCRIPT ROW, not just the visible ones: the frame describes the whole content, so a change
5985
6184
  // in height above the window is visible to the diff. `start` is the window offset, used by the painter.
5986
- for (let i = 0; i < Math.max(total, bodyH); i++) {
6185
+ //
6186
+ // THE LOOP RUNS FROM `start`, NOT FROM 0. `start` is NEGATIVE while the transcript is shorter than the
6187
+ // body — that negative value IS the padding, and `screenRow = idx - start` turns it into blank rows above
6188
+ // the conversation. Starting at 0 instead made the first message land on screen row 20 in a window that has
6189
+ // only `bodyH` rows, so its rows fell outside the window and were composed but never painted: a brand-new
6190
+ // session showed its messages at the top with an empty area beneath them.
6191
+ const loopFrom = Math.min(0, start);
6192
+ for (let i = loopFrom; i < Math.max(total, bodyH); i++) {
5987
6193
  const idx = i;
5988
- // Where this row lands on screen. The window starts at `start` (`-padding` while the transcript is
5989
- // shorter than the body), so a row outside [0, bodyH) is composed but never painted.
5990
- const screenRow = idx - Math.max(0, start);
6194
+ // A negative `idx` is padding: there is no transcript row there, and `row` stays empty.
6195
+ const screenRow = idx - start;
5991
6196
  let row = '';
5992
6197
  if (idx >= 0 && idx < total) {
5993
6198
  row = chat[idx] || '';
@@ -5996,6 +6201,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5996
6201
  //
5997
6202
  // It used to pass the transcript index here while the loop still walked the whole transcript, so the
5998
6203
  // rail was drawn from the geometry of the FIRST `bodyH` rows of the conversation and every row actually
6204
+ // rail was drawn from the geometry of the FIRST `bodyH` rows of the conversation and every row actually
5999
6205
  // on screen fell outside that range — the bar came out blank as soon as the transcript was scrolled off
6000
6206
  // the top, i.e. in the normal case, and only appeared when the window happened to start at row 0.
6001
6207
  if (sb && screenRow >= 0 && screenRow < bodyH) {
@@ -6040,7 +6246,11 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6040
6246
  row = fitAnsi(row, w - 1) + fg + bg + glyphChar + C.reset;
6041
6247
  }
6042
6248
  }
6043
- body.push(row);
6249
+ // `body` IS INDEXED BY TRANSCRIPT ROW, so the padding must not be appended to it. The loop starts at
6250
+ // `start` (negative while padded) to place rows on screen; `body` is what the rest of this function reads
6251
+ // back by index — the image resolver does `parseImageRow(body[i])` with a transcript index — so appending
6252
+ // padding rows would shift every lookup by the pad height.
6253
+ if (idx >= 0) body.push(row);
6044
6254
  // Only the window reaches the frame; the rest lives in `body` for the diff to compare.
6045
6255
  if (screenRow >= 0 && screenRow < bodyH) lines.push(row);
6046
6256
  }
@@ -6347,21 +6557,25 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6347
6557
  // earlier question with the mouse; without these the labels were plain text and a click did nothing.
6348
6558
  const tabHits = [];
6349
6559
  {
6350
- let tabs = '';
6351
- const tabName = (i) => {
6352
- const it = qs.items[i];
6353
- return String((it && it.header) || `Q${i + 1}`).trim() || `Q${i + 1}`;
6354
- };
6355
- for (let i = 0; i <= qs.items.length; i++) {
6356
- const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
6357
- const on = i === qs.index;
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 });
6362
- tabs += ' ';
6560
+ // ONE QUESTION MEANS NO TABS. There is nothing to switch to, and for a free-text question the only
6561
+ // tab would read "Other" beside a field that is already the answer.
6562
+ if (qs.items.length > 1) {
6563
+ let tabs = '';
6564
+ const tabName = (i) => {
6565
+ const it = qs.items[i];
6566
+ return String((it && it.header) || `Q${i + 1}`).trim() || `Q${i + 1}`;
6567
+ };
6568
+ for (let i = 0; i <= qs.items.length; i++) {
6569
+ const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
6570
+ const on = i === qs.index;
6571
+ const colStart = visualCol(tabs);
6572
+ const label = ' ' + name + ' ';
6573
+ tabs += on ? col(label, C.selBg + C.cyan + C.bold) : col(label, C.gray);
6574
+ tabHits.push({ index: i, colStart, colEnd: colStart + visualCol(label) - 1 });
6575
+ tabs += ' ';
6576
+ }
6577
+ box.push(boxRow(fitAnsi(tabs, innerW - 2)));
6363
6578
  }
6364
- box.push(boxRow(fitAnsi(tabs, innerW - 2)));
6365
6579
  }
6366
6580
  const itemRows = [];
6367
6581
  let caretInBox = -1;
@@ -6384,6 +6598,29 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6384
6598
  box.push(boxRow(col(' ' + seg, C.white)));
6385
6599
  }
6386
6600
  // Options: the model's options, then our per-question "Other" (free answer).
6601
+ // A FREE-TEXT QUESTION DRAWS ITS FIELD, NOT A LIST.
6602
+ //
6603
+ // `options` is empty for one of these, so the list below would render nothing but the "Other" row and
6604
+ // leave the user looking for a way to type. The field IS the question, so it goes where the options
6605
+ // would have been and starts open.
6606
+ if (cur.freeText) {
6607
+ // THE FIELD IS THE ONLY INTERACTIVE ROW, so it carries the hit: a click on it keeps focus where it already
6608
+ // is. Without a hitbox the press fell through to "click outside the dialog", which dismissed the question
6609
+ // instead of placing the caret.
6610
+ itemRows.push({ boxIdx: box.length, index: 0 });
6611
+ // THE LIVE BUFFER, not the committed value. Typing writes `editingText`; `otherText` only holds what was
6612
+ // committed when the field was left, so rendering it made the field look empty while the user typed into it.
6613
+ const typed = qs.editing === 'other' ? String(qs.editingText || '') : (qs.otherText || '');
6614
+ // A PLACEHOLDER SITS INSIDE THE FIELD, grey, and vanishes on the first keystroke. As a row underneath it read
6615
+ // as a caption explaining a field that already looked self-explanatory.
6616
+ const shown = typed || 'type your answer, then Enter';
6617
+ const paint = typed ? C.white : C.gray;
6618
+ const head = col('❯ ', C.cyan) + col(shown, paint);
6619
+ const fill = ' '.repeat(Math.max(0, innerW - 2 - visualCol(shown)));
6620
+ caretInBox = box.length;
6621
+ caretColInBox = 2 + visualCol(typed);
6622
+ box.push(boxRow(col(head + fill, '')));
6623
+ } else {
6387
6624
  const opts = cur.options.map((o) => ({ label: o.label, description: o.description, kind: 'option' }));
6388
6625
  opts.push({ label: OTHER_LABEL, description: '', kind: 'other' });
6389
6626
  const boxBudget = (questionH || Infinity) - 2 - box.length;
@@ -6413,18 +6650,19 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6413
6650
  itemRows.push({ boxIdx: box.length, index: j });
6414
6651
  box.push(boxRow(fitAnsi(head + desc, innerW - 2)));
6415
6652
  });
6416
- if (qs.editing === 'other' && showHint) {
6417
- const label = OTHER_LABEL + ': ';
6418
- const text = String(qs.editingText || '');
6419
- const ec = Math.max(0, Math.min(text.length, qs.editingCaret == null ? text.length : qs.editingCaret));
6420
- caretInBox = box.length;
6421
- caretColInBox = visualCol(label) + visualCol(text.slice(0, ec));
6422
- box.push(boxRow(col(label, C.gray) + col(text, C.white)));
6423
- } else if (showHint) {
6424
- const hint = cur.multiSelect
6425
- ? '↑↓ move · Space toggle · Enter confirm · Esc dismiss'
6426
- : '↑↓ move · Enter select · Esc dismiss';
6427
- box.push(boxRow(col(hint, C.gray)));
6653
+ if (qs.editing === 'other' && showHint) {
6654
+ const label = OTHER_LABEL + ': ';
6655
+ const text = String(qs.editingText || '');
6656
+ const ec = Math.max(0, Math.min(text.length, qs.editingCaret == null ? text.length : qs.editingCaret));
6657
+ caretInBox = box.length;
6658
+ caretColInBox = visualCol(label) + visualCol(text.slice(0, ec));
6659
+ box.push(boxRow(col(label, C.gray) + col(text, C.white)));
6660
+ } else if (showHint) {
6661
+ const hint = cur.multiSelect
6662
+ ? '↑↓ move · Space toggle · Enter confirm · Esc dismiss'
6663
+ : '↑↓ move · Enter select · Esc dismiss';
6664
+ box.push(boxRow(col(hint, C.gray)));
6665
+ }
6428
6666
  }
6429
6667
  }
6430
6668
  const boxTop = lines.length;
@@ -6605,7 +6843,6 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6605
6843
  // between yellow and red — a slow breathing, not a movement. And the red half read as
6606
6844
  // an error state on a row that only means "working".
6607
6845
  //
6608
- //
6609
6846
  // The light is a QUARTER OF THE PHRASE WIDE, over a yellow highlight running left to right.
6610
6847
  // The phase comes from a WALL CLOCK, not the tick count — see the same block in formatToolLine.
6611
6848
  // The render path redraws far more often than the 80ms spinner tick, and a phase derived from
@@ -6795,6 +7032,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6795
7032
  rowAnsi = ' ' + col(pre, preCol) + C.white +
6796
7033
  fitAnsi(highlighted, Math.max(1, textW - visualCol(pre))) + ' ';
6797
7034
  } else {
7035
+ // THE INDENT IS NOT TEXT. A continuation row arrives as `cont + text` — two spaces standing in for
7036
+ // `❯ ` — and `rowMeta.start` is the offset of the TEXT, not of the row. Handing the whole row to
7037
+ // `highlightSelection` therefore shifted the selection by the prefix's width: the two blank columns lit
7038
+ // up and the highlight ran two characters early, which is the offset seen on every wrapped line.
7039
+ //
7040
+ // The first-row branch above already strips its prefix; this one has to strip the continuation's.
7041
+ const contWidth = rowMeta.preWidth != null ? rowMeta.preWidth : visualCol(pre);
7042
+ const indent = styledRow.slice(0, contWidth);
7043
+ const textPart = styledRow.slice(contWidth);
6798
7044
  const textStart = rowMeta.start != null ? rowMeta.start : 0;
6799
7045
  const highlighted = (sel && sel.anchor !== sel.head)
6800
7046
  ? highlightSelection(styledRow, textStart, sel.anchor, sel.head)
@@ -7580,22 +7826,36 @@ function fieldCaretCol(field, active) {
7580
7826
  const c = Math.max(0, Math.min(shown.length, field.caret || 0));
7581
7827
  return visualCol(shown.slice(0, c));
7582
7828
  }
7583
- // ---- prompt history (persisted across restarts) -----------------------------
7584
- // Kept in the hncode config directory so the history survives a restart; the
7585
- // in-memory array alone meant every relaunch started with an empty ↑ list.
7829
+ // ---- prompt history (persisted across restarts, PER SESSION) ----------------
7830
+ //
7831
+ // Kept in the hncode config directory so the history survives a restart; the in-memory array alone meant
7832
+ // every relaunch started with an empty ↑ list.
7833
+ //
7834
+ // THE FILE IS PER SESSION, not one shared list. A single `history.json` meant ↑ in one conversation offered
7835
+ // what you had typed in another — the transcript looked isolated while the keystroke history was not, so a
7836
+ // prompt from an unrelated session could be pulled into the composer and sent to the wrong agent.
7586
7837
  const HISTORY_MAX = 500;
7587
7838
 
7588
- function historyFile() {
7589
- try {
7590
- return path.join(path.dirname(hncodeConfigFile()), 'history.json');
7591
- } catch {
7592
- return path.join(os.homedir(), '.hncode', 'history.json');
7593
- }
7839
+ /**
7840
+ * The history file for a session. No id falls back to the shared file, which is what a session that was never
7841
+ * saved uses.
7842
+ *
7843
+ * @param {string} [sessionId]
7844
+ */
7845
+ function historyFile(sessionId) {
7846
+ const dir = (() => {
7847
+ try { return path.dirname(hncodeConfigFile()); } catch { return path.join(os.homedir(), '.hncode'); }
7848
+ })();
7849
+ // One directory, one file per session: the id is a hex-ish token, so it is safe as a filename, but it is
7850
+ // sanitised anyway — a session id from an imported file must not be able to name a path.
7851
+ const safe = String(sessionId || '').replace(/[^A-Za-z0-9_-]/g, '');
7852
+ if (!safe) return path.join(dir, 'history.json');
7853
+ return path.join(dir, 'history', `${safe}.json`);
7594
7854
  }
7595
7855
 
7596
- export function loadHistory() {
7856
+ export function loadHistory(sessionId) {
7597
7857
  try {
7598
- const raw = fs.readFileSync(historyFile(), 'utf8');
7858
+ const raw = fs.readFileSync(historyFile(sessionId), 'utf8');
7599
7859
  const arr = JSON.parse(raw);
7600
7860
  if (!Array.isArray(arr)) return [];
7601
7861
  return arr.filter((x) => typeof x === 'string' && x.trim() !== '').slice(-HISTORY_MAX);
@@ -7604,9 +7864,9 @@ export function loadHistory() {
7604
7864
  }
7605
7865
  }
7606
7866
 
7607
- export function saveHistory(list) {
7867
+ export function saveHistory(list, sessionId) {
7608
7868
  try {
7609
- const file = historyFile();
7869
+ const file = historyFile(sessionId);
7610
7870
  fs.mkdirSync(path.dirname(file), { recursive: true });
7611
7871
  // Newest last, capped, and de-duplicated while keeping order.
7612
7872
  const seen = new Set();
@@ -7621,6 +7881,52 @@ export function saveHistory(list) {
7621
7881
  } catch { /* history is a convenience; never break the session over it */ }
7622
7882
  }
7623
7883
 
7884
+ /** The directory holding one history file per session. */
7885
+ function historyDir() {
7886
+ const dir = (() => {
7887
+ try { return path.dirname(hncodeConfigFile()); } catch { return path.join(os.homedir(), '.hncode'); }
7888
+ })();
7889
+ return path.join(dir, 'history');
7890
+ }
7891
+
7892
+ /**
7893
+ * Forgets one session's prompt history.
7894
+ *
7895
+ * Called when a session is deleted: the file is keyed by session id and nothing else would ever remove it, so
7896
+ * a user who deleted a conversation would leave its prompts on disk forever.
7897
+ *
7898
+ * @param {string} sessionId
7899
+ * @returns {boolean} Whether a file was removed.
7900
+ */
7901
+ export function deleteHistory(sessionId) {
7902
+ try { fs.unlinkSync(historyFile(sessionId)); return true; } catch { return false; }
7903
+ }
7904
+
7905
+ /**
7906
+ * Drops history files whose session no longer exists.
7907
+ *
7908
+ * Every session that is ever started writes one, and nothing pruned them — a directory that grew by a file per
7909
+ * conversation. The session store is the authority on which ids are still real, so this is called once at
7910
+ * startup with that set.
7911
+ *
7912
+ * @param {Set<string>|string[]} liveIds Ids that still have a session file.
7913
+ * @returns {number} How many files were removed.
7914
+ */
7915
+ export function pruneHistory(liveIds) {
7916
+ const keep = liveIds instanceof Set ? liveIds : new Set(liveIds || []);
7917
+ const dir = historyDir();
7918
+ let names;
7919
+ try { names = fs.readdirSync(dir); } catch { return 0; } // no directory yet: nothing to prune
7920
+ let removed = 0;
7921
+ for (const name of names) {
7922
+ if (!name.endsWith('.json')) continue;
7923
+ const id = name.slice(0, -5);
7924
+ if (keep.has(id)) continue;
7925
+ try { fs.unlinkSync(path.join(dir, name)); removed++; } catch { /* locked or already gone */ }
7926
+ }
7927
+ return removed;
7928
+ }
7929
+
7624
7930
  // ---- state factory ----
7625
7931
  export function makeState({ cfg, session, opts }) {
7626
7932
  const cwd = session.workspace || cfg.workspace || process.cwd();
@@ -7805,7 +8111,8 @@ export function makeState({ cfg, session, opts }) {
7805
8111
  notice: '',
7806
8112
  noticeKind: 'info',
7807
8113
  confirmExit: false,
7808
- history: loadHistory(), // restored from disk so ↑ works across restarts
8114
+ // Per session: ↑ must offer what was typed in THIS conversation, not in another one.
8115
+ history: loadHistory(session && session.id),
7809
8116
  historyIdx: -1,
7810
8117
  _tipIndex: 0,
7811
8118
  // Provider-reported token totals for the whole session (see the `usage`
@@ -8349,6 +8656,10 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8349
8656
  if (key === 'probe_timeout_ms') cfg.probeTimeoutMs = Number(raw);
8350
8657
  if (key === 'log_level') cfg.logLevel = raw;
8351
8658
  if (key === 'shell') cfg.shell = raw;
8659
+ // The WebSearch keys are read as `cfg.webSearchApiKey` (the camelCase built in resolveConfig), not as
8660
+ // `cfg.web_search_api_key`. Without this the value reached the file and the raw root but NOT the field the
8661
+ // tool reads, so editing it appeared to work and changed nothing until a restart.
8662
+ if (key === 'web_search_api_key') cfg.webSearchApiKey = raw;
8352
8663
  }
8353
8664
 
8354
8665
  // Effects that are not a plain field: switching the theme has to repaint every cell,
@@ -8379,9 +8690,9 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8379
8690
  openPicker({
8380
8691
  title: 'Select permission mode',
8381
8692
  items: [
8382
- { label: 'Ask', sub: 'read-only runs automatically; every other action asks first', current: state.mode === 'ask', value: 'ask' },
8383
- { label: 'Yolo', sub: 'nothing asks: edits and commands run automatically, inside the workspace or not', current: state.mode === 'yolo', value: 'yolo' },
8384
- { label: 'Auto', sub: 'nothing asks and nothing is reviewed: everything runs and is decided automatically', current: state.mode === 'auto', value: 'auto' },
8693
+ { label: 'Ask', sub: 'read-only tools and read-only shell commands run automatically; everything else asks', current: state.mode === 'ask', value: 'ask' },
8694
+ { label: 'Yolo', sub: 'runs anything inside the workspace, and any non-destructive command; asks for a path OUTSIDE the workspace and for a destructive command', current: state.mode === 'yolo', value: 'yolo' },
8695
+ { label: 'Auto', sub: 'never asks and never waits for approval: everything runs and is decided automatically (an explicit deny or ask rule still wins)', current: state.mode === 'auto', value: 'auto' },
8385
8696
  ],
8386
8697
  sel: ['ask', 'yolo', 'auto'].indexOf(state.mode),
8387
8698
  searchable: false,
@@ -8427,6 +8738,30 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8427
8738
  app(`Focus mode: ${state.focus ? 'ON (Read/Write/Edit/Bash only)' : 'OFF (all tools)'}`);
8428
8739
  return;
8429
8740
  }
8741
+ case 'websearch-api': {
8742
+ // ONE KEY PER LINE, EDITED IN PLACE, LIVE ON SAVE.
8743
+ //
8744
+ // This is the direct path to the setting that decides whether WebSearch works at all: keyless mode is
8745
+ // rate-limited per IP, and when it runs out the tool can only report refusals. A multi-line editor is
8746
+ // the right shape because the value IS a list — several keys make the monthly quota additive — and
8747
+ // because a pasted key is long enough that a one-line field would scroll it out of view.
8748
+ openEditor({
8749
+ title: 'WebSearch API keys — one per line, tried in order',
8750
+ text: String(effectiveValue(cfg, 'web_search_api_key')),
8751
+ caretRow: 0,
8752
+ caretCol: 0,
8753
+ // `applySetting` writes config.toml AND updates `cfg.webSearchApiKey`, which is the field the tool
8754
+ // reads — so the next search uses the new keys without a restart.
8755
+ onSave: (value) => {
8756
+ applySetting('web_search_api_key', String(value == null ? '' : value));
8757
+ const count = String(value || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean).length;
8758
+ app(count
8759
+ ? `Saved ${count} WebSearch key${count === 1 ? '' : 's'}. The next search uses them.`
8760
+ : 'WebSearch keys cleared — searches use keyless mode (rate-limited per IP).');
8761
+ },
8762
+ });
8763
+ return;
8764
+ }
8430
8765
 
8431
8766
  case 'settings': {
8432
8767
  // A PICKER, not a screen takeover. Every other chooser in hncode is a picker — /model,
@@ -9098,6 +9433,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9098
9433
 
9099
9434
  state.chat = [];
9100
9435
  Object.assign(session, { id: sess.newId(), title: '', messages: [], createdAt: Date.now(), rounds: 0, steps: 0, lastTurnMs: 0 });
9436
+ // A NEW SESSION HAS NO HISTORY. The list is per session now, so leaving the previous one in place would
9437
+ // offer prompts from the conversation that was just abandoned.
9438
+ state.history = loadHistory(session.id);
9439
+ state.historyIdx = -1;
9440
+ state.draftInput = '';
9101
9441
  state.objective = '';
9102
9442
  state.rounds = 0; state.steps = 0; state.lastTurnMs = 0;
9103
9443
  // New session starts with empty TODO list
@@ -9153,11 +9493,28 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9153
9493
  }
9154
9494
  openPicker({
9155
9495
  title: everyWorkspace ? 'Resume a session (all workspaces)' : 'Resume a session',
9496
+ // The hint names the delete key: a listener with no advertised shortcut is the same as no feature.
9497
+ hint: '↑↓ move · Enter resume · Del delete · Esc close',
9156
9498
  items: list.slice(0, 50).map((s) => ({
9157
9499
  label: (s.title || 'untitled').slice(0, 30),
9158
9500
  sub: `${new Date(s.updatedAt || s.createdAt || 0).toLocaleString().slice(0, 19)} · ${(s.messages || []).length} msgs · ${s.workspace || ''}${s.lastTurnMs ? ` · last turn ${fmtDuration(s.lastTurnMs)}` : ''}`,
9159
9501
  id: s.id,
9160
9502
  })),
9503
+ // DELETING A SESSION HAS TO TAKE ITS PROMPT HISTORY WITH IT.
9504
+ //
9505
+ // The history file is keyed by session id and nothing else would ever remove it, so a deleted
9506
+ // conversation left its prompts on disk forever — invisible, growing, and readable.
9507
+ onDelete: (it) => {
9508
+ if (!it || !it.id) return false;
9509
+ if (it.id === session.id) {
9510
+ appErr('That is the session you are in. Use /new first, or pick another one.');
9511
+ return false;
9512
+ }
9513
+ const ok = sess.deleteSession(it.id);
9514
+ deleteHistory(it.id);
9515
+ app(ok ? `Deleted session ${it.id.slice(0, 8)}.` : `Could not delete ${it.id.slice(0, 8)}.`);
9516
+ return true;
9517
+ },
9161
9518
  onPick: (it) => {
9162
9519
  const meta = list.find((s) => s.id === it.id);
9163
9520
  if (meta) {
@@ -9179,6 +9536,12 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9179
9536
  // never showing one.
9180
9537
  writeWindowTitle(session, stdout);
9181
9538
  state.chat = reconstructChat(session);
9539
+ // THE KEYSTROKE HISTORY FOLLOWS THE CONVERSATION. It is stored per session, so switching without
9540
+ // reloading it left ↑ offering the prompts of the session just left — the transcript changed and
9541
+ // the history underneath it did not.
9542
+ state.history = loadHistory(session.id);
9543
+ state.historyIdx = -1;
9544
+ state.draftInput = '';
9182
9545
  state.scroll = 0;
9183
9546
  dropScrollPin(state); // fresh transcript: re-seed the anchor on paint
9184
9547
  state.rounds = session.rounds || 0;
@@ -9249,7 +9612,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9249
9612
  // Plan / Focus / Swarm are one mutually exclusive group: turning one on
9250
9613
  // turns the other two off.
9251
9614
  if (want) { state.plan = false; state.focus = false; }
9252
- try { setConfigString('swarm_mode', want ? 'true' : 'false'); } catch {}
9615
+ // A write that fails SILENTLY still shows the success message below, so the user believes the setting
9616
+ // stuck and it comes back wrong after a restart.
9617
+ try { setConfigString('swarm_mode', want ? 'true' : 'false'); } catch (e) {
9618
+ appErr(`Could not save this setting: ${e.message}. It applies to this session only.`);
9619
+ }
9253
9620
  cfg.raw.swarm_mode = want;
9254
9621
  app(want
9255
9622
  ? 'Swarm mode ON - the model will decompose work across parallel subagents (AgentSwarm)'
@@ -9502,8 +9869,24 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9502
9869
  // agent.js: a reasoning model can spend the whole output budget on
9503
9870
  // `reasoning_content` and return an empty summary, which turns /compact
9504
9871
  // into a plain loss of history.
9872
+ // THE SUMMARIZER MUST NOT TRY TO CALL A TOOL.
9873
+ //
9874
+ // It runs with `noTools: true`, so the tools are absent from the request — but a model that has just
9875
+ // been calling them keeps emitting the markup anyway, and its whole reply comes back as a tool call
9876
+ // in whatever dialect it uses (`<||DSML||invoke name="…">`, `<function_call>`, a JSON block). The
9877
+ // summary is then that markup, and the history it was supposed to preserve is replaced by it.
9878
+ //
9879
+ // Stating the rule, the exact tags to avoid, and where the reply goes is what stops it: the output
9880
+ // must be PROSE, because it is inserted verbatim into the next prompt as context.
9505
9881
  summary = await llm.requestText([
9506
- { role: 'system', content: 'You are an expert at summarizing coding-agent conversations. Summarize the conversation history below. Capture: the user\'s original request, key decisions made, files created or modified, errors encountered and how they were resolved, and the current state of any ongoing work or remaining tasks. Be concise but thorough — aim for 3-5 short paragraphs that let the model continue the task with full context. Do NOT include meta-commentary, only the facts.' },
9882
+ {
9883
+ role: 'system',
9884
+ content: 'You are an expert at summarizing coding-agent conversations. Summarize the conversation history below. Capture: the user\'s original request, key decisions made, files created or modified, errors encountered and how they were resolved, and the current state of any ongoing work or remaining tasks. Be concise but thorough — aim for 3-5 short paragraphs that let the model continue the task with full context. Do NOT include meta-commentary, only the facts.\n\n'
9885
+ + 'THIS IS A SUMMARY, NOT A TOOL CALL. No tools are available and none may be requested. Do not '
9886
+ + 'emit any tool-call markup of any kind — no `<||DSML||…>`, no `<function_call>`, no JSON tool '
9887
+ + 'block, no XML tags at all. Reply in plain prose only: the text you write is stored verbatim as '
9888
+ + 'context for the next turn, so any markup in it becomes part of the conversation.',
9889
+ },
9507
9890
  ...droppedMsgs,
9508
9891
  ], { noReasoning: true, noTools: true });
9509
9892
  } catch (e) {
@@ -10053,14 +10436,65 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10053
10436
  if (a.startsWith('replace ')) { applyGoal(raw.slice(8).trim(), false); app('Goal replaced.'); return; }
10054
10437
  applyGoal(raw, false);
10055
10438
  app(`Goal set: ${raw}`);
10056
- sendPrompt(raw);
10439
+ // TELL THE MODEL IT IS IN GOAL MODE.
10440
+ //
10441
+ // Sending the objective alone read as an ordinary instruction: the model answered, stopped, and waited —
10442
+ // not knowing that stopping ends nothing because the loop reopens the work. It also had no way to learn the
10443
+ // loop exists, so it never used the Goal tool to clear the objective and the restart happened for ever.
10444
+ //
10445
+ // The text names the objective, says the turn will be reopened until it is met, and names the tool that
10446
+ // ends it. The loop's own restart message says the same three things, so the two cannot drift apart.
10447
+ sendPrompt([
10448
+ `You are now working towards a GOAL: ${raw}`,
10449
+ '',
10450
+ 'The turn loop reopens this work until the goal is met or cleared, so do not stop early and do not ask',
10451
+ 'whether to continue — keep working. When the goal is fully met, call the Goal tool with action "clear"',
10452
+ 'so the loop stops. If it is not met yet, report what you did and keep going.',
10453
+ ].join('\n'));
10057
10454
  return;
10058
10455
  }
10059
10456
 
10060
- case 'help':
10061
- h.openPanel('hncode — commands', [
10062
- ...allCommands().map((c) => ` /${c.name.padEnd(14)} ${(c.argumentHint || '').padEnd(26)} ${c.description || c.desc}`),
10063
- '',
10457
+ case 'help': {
10458
+ // GROUPED, and filterable: `/help git` shows only what matches.
10459
+ //
10460
+ // The commands were one flat list — 91 entries, each with a one-line description, in registration order.
10461
+ // Nothing in that showed what was related to what, or let a user find one without reading all of them.
10462
+ const filter = String(raw || '').trim().toLowerCase();
10463
+ const groups = [
10464
+ ['Everyday', ['settings', 'set', 'model', 'provider', 'permission', 'yolo']],
10465
+ ['Session', ['new', 'sessions', 'all-sessions', 'resume', 'save', 'export', 'compact', 'goal', 'init']],
10466
+ ['Context', ['context', 'tokens', 'usage', 'cost', 'memory', 'personal', 'output-style']],
10467
+ ['Work', ['plan', 'focus', 'swarm', 'team', 'btw', 'tasks']],
10468
+ ['Extend', ['skills', 'plugins', 'import-skill', 'plugin-manager']],
10469
+ ['Diagnostics', ['doctor', 'status', 'version', 'raw', 'reduced-motion', 'help']],
10470
+ ];
10471
+ const byName = new Map(allCommands().map((c) => [c.name, c]));
10472
+ const shown = new Set();
10473
+ const row = (c) => ` /${c.name.padEnd(14)} ${(c.argumentHint || '').padEnd(26)} ${c.description || c.desc}`;
10474
+ const hits = (c) => !filter || `${c.name} ${c.desc || c.description || ''}`.toLowerCase().includes(filter);
10475
+
10476
+ const panel = [];
10477
+ for (const [title, names] of groups) {
10478
+ const rows = names.map((n) => byName.get(n)).filter(Boolean).filter(hits);
10479
+ for (const c of rows) shown.add(c.name);
10480
+ if (!rows.length) continue;
10481
+ panel.push(title);
10482
+ for (const c of rows) panel.push(row(c));
10483
+ panel.push('');
10484
+ }
10485
+ // Anything the groups did not name still has to be reachable — a command missing from this list is a
10486
+ // command the user cannot discover, so the remainder is listed rather than dropped.
10487
+ const rest = allCommands().filter((c) => !shown.has(c.name)).filter(hits);
10488
+ if (rest.length) {
10489
+ panel.push('Other');
10490
+ for (const c of rest) panel.push(row(c));
10491
+ panel.push('');
10492
+ }
10493
+ if (!panel.some((l) => l.startsWith(' /'))) {
10494
+ panel.push(`No command matches "${filter}".`, '');
10495
+ }
10496
+ h.openPanel(filter ? `hncode — commands matching "${filter}"` : 'hncode — commands', [
10497
+ ...panel,
10064
10498
  'Skills',
10065
10499
  ` /${SKILL_PREFIX}<name> run an installed skill (Tab completes the name)`,
10066
10500
  ' /import-skill <file.md> add or overwrite a skill from a Markdown file',
@@ -10088,6 +10522,8 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10088
10522
  ' Ctrl-C twice exit hncode',
10089
10523
  ]);
10090
10524
  return;
10525
+ }
10526
+
10091
10527
  case 'status': {
10092
10528
  const lines = [
10093
10529
  `hncode v${VERSION}`,
@@ -10309,7 +10745,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10309
10745
  else if (a === '') want = !state.reducedMotion;
10310
10746
  else { appErr('Usage: /reduced-motion [on|off]'); return; }
10311
10747
  state.reducedMotion = want;
10312
- try { setConfigString('reduced_motion', want ? 'true' : 'false'); } catch { /* not fatal */ }
10748
+ try { setConfigString('reduced_motion', want ? 'true' : 'false'); } catch (e) { appErr(`Could not save this setting: ${e.message} — it applies to this session only.`); }
10313
10749
  app(want
10314
10750
  ? 'Reduced motion: ON — animation is replaced by a slow-breathing dot.'
10315
10751
  : 'Reduced motion: OFF — animation restored.');
@@ -10326,7 +10762,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10326
10762
  }
10327
10763
  if (a !== 'cosine' && a !== 'linear') { appErr('Usage: /shimmer-edge cosine|linear'); return; }
10328
10764
  state.shimmerEdge = a;
10329
- try { setConfigString('shimmer_edge', a); } catch { /* not fatal */ }
10765
+ try { setConfigString('shimmer_edge', a); } catch (e) { appErr(`Could not save this setting: ${e.message} — it applies to this session only.`); }
10330
10766
  app(`Shimmer edge: ${a}.`);
10331
10767
  return;
10332
10768
  }
@@ -10339,7 +10775,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10339
10775
  if (lower === 'off' || lower === 'none' || lower === 'clear') {
10340
10776
  state.outputStyle = null;
10341
10777
  cfg.outputStyle = null;
10342
- try { setConfigString('output_style', ''); } catch { /* not fatal */ }
10778
+ try { setConfigString('output_style', ''); } catch (e) { appErr(`Could not save this setting: ${e.message} — it applies to this session only.`); }
10343
10779
  app('Output style cleared.');
10344
10780
  return;
10345
10781
  }
@@ -10409,13 +10845,13 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10409
10845
  onPick: (it) => {
10410
10846
  if (it.action === 'none') {
10411
10847
  state.outputStyle = null; cfg.outputStyle = null;
10412
- try { setConfigString('output_style', ''); } catch { /* not fatal */ }
10848
+ try { setConfigString('output_style', ''); } catch (e) { appErr(`Could not save this setting: ${e.message} — it applies to this session only.`); }
10413
10849
  app('Output style cleared.');
10414
10850
  return true;
10415
10851
  }
10416
10852
  state.outputStyle = it.style;
10417
10853
  cfg.outputStyle = it.style.name;
10418
- try { setConfigString('output_style', it.style.name); } catch { /* not fatal */ }
10854
+ try { setConfigString('output_style', it.style.name); } catch (e) { appErr(`Could not save this setting: ${e.message} — it applies to this session only.`); }
10419
10855
  app(`Output style → ${it.style.name}`);
10420
10856
  return true;
10421
10857
  },
@@ -10426,7 +10862,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
10426
10862
  if (!found) { appErr(`No style matching "${arg}". /output-style to list them.`); return; }
10427
10863
  state.outputStyle = found;
10428
10864
  cfg.outputStyle = found.name;
10429
- try { setConfigString('output_style', found.name); } catch { /* not fatal */ }
10865
+ try { setConfigString('output_style', found.name); } catch (e) { appErr(`Could not save this setting: ${e.message} — it applies to this session only.`); }
10430
10866
  app(`Output style → ${found.name}`);
10431
10867
  return;
10432
10868
  }
@@ -11705,7 +12141,6 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
11705
12141
  title: 'Status line items',
11706
12142
  title: 'Status line items',
11707
12143
  items: (() => {
11708
- // `sub` must be set here, not only in onPick: the picker renders
11709
12144
  // `sub` must be set here, not only in onPick: the picker renders
11710
12145
  // item.sub, so without an initial value every toggle showed a blank
11711
12146
  // state until the user toggled it once.
@@ -12494,6 +12929,18 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
12494
12929
  // leading slash depending on the caller. It goes out with ONE slash, so the model sees the message
12495
12930
  // as the person wrote it rather than as a bare word the composer mangled.
12496
12931
  const asTyped = `/${String(cmdRaw || '').replace(/^\/+/, '')}`.trim();
12932
+ // AND IT OBEYS THE SAME QUEUE RULE AS ANY OTHER MESSAGE.
12933
+ //
12934
+ // A turn is running, so this text is not a command and it is not something to inject into the
12935
+ // request already in flight — it is the user's next message, exactly like a line that does not
12936
+ // start with a slash. `sendPrompt` would hand it to the RUNNING turn, which is the one thing the
12937
+ // plain-text path deliberately avoids (Ctrl-S is the explicit way to inject). Typing `/some note`
12938
+ // mid-turn therefore went out immediately while typing `some note` waited for the turn to end.
12939
+ if (state.running && state.agent) {
12940
+ state.queued.push(asTyped);
12941
+ notice(`No such command: ${asTyped.split(/\s+/)[0]} — queued as a message`, 'info');
12942
+ return;
12943
+ }
12497
12944
  notice(`No such command: ${asTyped.split(/\s+/)[0]} — sent as a message instead`, 'info');
12498
12945
  await sendPrompt(asTyped);
12499
12946
  return;
@@ -13589,7 +14036,10 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13589
14036
  // this list because ASKING the user is not a side effect — gating it behind
13590
14037
  // an approval prompt would mean answering two prompts to ask one question.
13591
14038
  // (In auto mode the TOOL refuses itself before any UI; see ctx.permissionMode.)
13592
- const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'FileLines', 'AskUserQuestion'];
14039
+ // Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
14040
+ // `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
14041
+ // approval in yolo — the one mode whose point is not asking.
14042
+ const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
13593
14043
  if (rule !== 'ask' && safeTools.includes(toolName)) return true;
13594
14044
  // A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
13595
14045
  // read-only TOOL, so it runs without a prompt in Ask mode too — the user
@@ -13668,8 +14118,17 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13668
14118
  editingCaret: 0,
13669
14119
  otherText: '',
13670
14120
  supplement: '',
13671
- resolve: (answers) => resolve(answers),
13672
- };
14121
+ resolve: (answers) => resolve(answers),
14122
+ };
14123
+ // FOCUS THE FIRST QUESTION if it is free text: it has one row and that row is a field.
14124
+ {
14125
+ const q0 = questions && questions[0];
14126
+ if (q0 && q0.freeText) {
14127
+ state.question.editing = 'other';
14128
+ state.question.editingText = '';
14129
+ state.question.editingCaret = 0;
14130
+ }
14131
+ }
13673
14132
  // An aborted turn must not leave the dialog stranded on screen.
13674
14133
  const sig = ctx && ctx.signal;
13675
14134
  const onAbort = () => {
@@ -13882,7 +14341,10 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13882
14341
  //
13883
14342
  // `ctxReportedOnce` latches when a real figure arrives. After that the estimate never wins.
13884
14343
  // The decision lives in `setCtxTokens` so every writer obeys the same rule.
13885
- if (!state.ctxReportedOnce) state.ctxTokens = Number(e.tokens) || 0;
14344
+ // EVERY WRITER GOES THROUGH `setCtxTokens`, which is what keeps `ctxPercent` in step with
14345
+ // `ctxTokens`. This one assigned the field directly, so the gauge's number moved and its percentage
14346
+ // did not — the row read `696k/1M` beside a figure computed from the previous value.
14347
+ sessHost.setCtxTokens(Number(e.tokens) || 0, null, { fromEstimate: true });
13886
14348
  viewCur.renderSoon(); return;
13887
14349
  }
13888
14350
  if (e.type === 'usage') {
@@ -13932,13 +14394,18 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
13932
14394
  // That makes the local estimate unnecessary whenever this arrives, and having both is what
13933
14395
  // produced a gauge alternating between 150k and 300k: the estimate and the report describe
13934
14396
  // different requests and overwrote each other. The report wins and the estimate stands down.
13935
- // Routed through `setCtxTokens`, which latches the switch and mirrors onto the session so a
13936
- // resume starts from the reported size. Duplicating those fields here is what let a later
13937
- // estimate overwrite them.
13938
- state.ctxTokens = reported;
13939
- state.ctxReportedOnce = true;
13940
- state.ctxSource = 'provider';
13941
- if (session) { session.ctxTokens = reported; session.ctxReportedOnce = true; }
14397
+ // Routed through `setCtxTokens`, which latches the switch, mirrors onto the session so a resume
14398
+ // starts from the reported size, AND recomputes the percentage. An earlier version assigned the
14399
+ // two fields by hand and left the percentage on the estimate — the gauge showed `696k/1M` beside a
14400
+ // number computed from a different source.
14401
+ sessHost.setCtxTokens(reported, null, { reported: true });
14402
+ // ...AND ONTO DISK, so a restart starts from the real size rather than the estimate.
14403
+ //
14404
+ // `setCtxTokens` only mirrors onto the in-memory session object. Without a save the figure
14405
+ // lived until the process exited, and `hncode --continue` reopened a session whose gauge had
14406
+ // silently dropped back to the local estimate — the value was in the session the whole time,
14407
+ // just never written. One save per reply is cheap and it is the only copy that outlives us.
14408
+ try { sessHost.persistState(); } catch { /* a failed save must not disturb the turn */ }
13942
14409
  }
13943
14410
  refreshCost(state, cfg);
13944
14411
  viewCur.renderSoon(); return;
@@ -14207,7 +14674,15 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14207
14674
  // here: the reply below already schedules one, and the async git result asks for a repaint
14208
14675
  // of its own when it lands.
14209
14676
  refreshGitInfo(state, { force: true });
14210
- } else if (e.type === 'error') viewCur.addChat({ role: 'tool_result', text: (e.error && e.error.message) || String(e.error) });
14677
+ } else if (e.type === 'error') {
14678
+ // REMEMBER THAT THIS TURN FAILED, so the goal loop below does not restart it.
14679
+ //
14680
+ // A restart is only justified when the turn ENDED — the model stopped with work left. When it
14681
+ // ended because the request was refused (no credit, bad key, rate limit) restarting repeats the
14682
+ // same refusal, which spends whatever quota is left and buries the error under retries.
14683
+ state._turnFailed = true;
14684
+ viewCur.addChat({ role: 'tool_result', text: (e.error && e.error.message) || String(e.error) });
14685
+ }
14211
14686
  else if (e.type === 'truncation_recovery') {
14212
14687
  // The answer hit the output cap mid-way and the agent asked the model to
14213
14688
  // continue. Without a row here the transcript simply grew a second
@@ -14389,6 +14864,17 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14389
14864
  session.messages = messages;
14390
14865
  session.rounds = state.rounds;
14391
14866
  session.steps = state.steps;
14867
+ // THE GAUGE TRAVELS WITH THE MESSAGES.
14868
+ //
14869
+ // These four are what a restart reads back: without them the conversation was restored and the token
14870
+ // count was not, so the first frame after `--continue` priced the whole transcript locally and showed a
14871
+ // smaller figure than the session had actually ended at. Written here because this is the one place the
14872
+ // turn-end values are final.
14873
+ if (Number.isFinite(state.ctxTokens) && state.ctxTokens > 0) {
14874
+ session.ctxTokens = state.ctxTokens;
14875
+ session.ctxMax = state.ctxMax;
14876
+ session.ctxReportedOnce = !!state.ctxReportedOnce;
14877
+ }
14392
14878
  session.rounds = state.rounds;
14393
14879
  session.steps = state.steps;
14394
14880
  // The todo list is part of the session's visible state: persist it here
@@ -14487,8 +14973,26 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14487
14973
  // Skipped in every case where the turn did not end normally: a dismissal means the user wants it to stop,
14488
14974
  // and a queued message is already the next turn (restarting here would run both).
14489
14975
  const goalNow = String(state.objective || '').trim();
14490
- const goalApplies = goalNow && !state.goalPaused && !approvedPlan && !state.queued.length
14976
+ // A FAILED TURN IS NOT A FINISHED ONE, and it must not be restarted.
14977
+ //
14978
+ // The restart exists for "the model stopped with work left". When the turn ended because the request was
14979
+ // REFUSED — no credit, an invalid key, a rate limit — restarting sends the same request into the same
14980
+ // wall. It spends whatever quota remains, and the transcript fills with retries until the real error is
14981
+ // buried. `_turnFailed` is set by the error handler above.
14982
+ const goalApplies = goalNow && !state.goalPaused && !state._turnFailed && !approvedPlan && !state.queued.length
14491
14983
  && !state._goalTurns; // set below, so one restart per goal per turn
14984
+ // GOAL PAUSED AFTER A FAILURE — and said out loud.
14985
+ //
14986
+ // The objective stays set so the work can continue, but the loop does not retry a turn that was refused:
14987
+ // that would re-spend quota on a request the endpoint already declined. Telling the user is the other half
14988
+ // — a silent stop after an error reads as "the agent finished" when in fact nothing was accomplished.
14989
+ if (goalNow && !state.goalPaused && state._turnFailed) {
14990
+ viewCur.addChat({
14991
+ role: 'system',
14992
+ text: '[goal] not restarted: the turn failed. Fix the cause, then /goal resume to continue.',
14993
+ });
14994
+ state.goalPaused = true;
14995
+ }
14492
14996
  if (goalApplies) {
14493
14997
  state._goalTurns = (state._goalTurns || 0) + 1;
14494
14998
  viewCur.addChat({
@@ -14550,27 +15054,21 @@ export function writeWindowTitle(session, stdout) {
14550
15054
  export async function startTUI(opts) {
14551
15055
  const { cfg } = opts;
14552
15056
  // `let`, and mirrored on `state.session`, because /move REPLACES the session: the command
14553
- // `let`, and mirrored on `state.session`, because /move REPLACES the session: the command
14554
15057
  // hands back a new object, and every later save would otherwise keep writing the old one —
14555
15058
  // which is how /move left the session stamped with the directory it was moved away from.
14556
15059
  let session = opts.session;
14557
15060
 
14558
- /**
14559
- * THE ONLY WRITER of the context gauge.
14560
- *
14561
- * Every source goes through here — a live `context` event, session restore, compaction, and the
14562
- * initial load. Before this existed the gauge had seven assignment sites and two different formulas:
14563
- * restore counted the messages alone while a live step counted messages PLUS the tool schemas, so the
14564
- * number stepped by a fixed amount whenever the two paths alternated — a visible jump caused by two
14565
- * formulas disagreeing, not by noise.
14566
- *
14567
- * Keeping one entry point is also what makes the number honest: there is exactly one definition of
14568
- * "how much context is in use", so a future caller cannot invent a third.
14569
- */
15061
+ // HOUSEKEEPING: drop prompt-history files whose session no longer exists. One file is written per
15062
+ // conversation and nothing else removes them, so without this the directory grows by a file per session with
15063
+ // no ceiling. `listSessions` already reads the session store, which is the authority on what is still real.
15064
+ try { pruneHistory(sess.listSessions().map((s) => s.id)); } catch { /* pruning is housekeeping */ }
15065
+
14570
15066
  /**
14571
15067
  * The one place the context gauge is set.
14572
15068
  *
14573
15069
  * `opts.fromEstimate` marks a number the local tokenizer produced. Those are only used until the
15070
+ *
15071
+ * `opts.fromEstimate` marks a number the local tokenizer produced. Those are only used until the
14574
15072
  * endpoint reports a real size — measured against it, the estimate runs ~1.8x high (a 4 000-character
14575
15073
  * message is 530 tokens, the estimate says 953), so once a report exists the estimate is ignored and
14576
15074
  * the gauge stays on the reported figure instead of jumping between the two.
@@ -15502,26 +16000,159 @@ export async function startTUI(opts) {
15502
16000
  notice: '',
15503
16001
  noticeKind: 'info',
15504
16002
  onSave: spec.onSave || null,
16003
+ // A SELECTION, for the mouse. `anchor` is where a drag started and `head` is where it is now; they are
16004
+ // `{ row, col }` in TEXT coordinates. Without one the editor had no way to express "these characters",
16005
+ // so Copy/Cut and a drag could only ever act on the whole buffer or on nothing.
16006
+ sel: null,
16007
+ // True while the button is down and the pointer is moving — the drag that defines the selection.
16008
+ dragging: false,
15505
16009
  };
15506
16010
  state.panel = null; state.picker = null; state.pickerQuery = ''; state.pickerCategory = null; state.form = null;
15507
16011
  state.menuOpen = false; state.menuList = []; state.menuSel = 0;
15508
16012
  renderFrame();
15509
16013
  }
15510
16014
 
16015
+ // ---- editor selection -------------------------------------------------------
16016
+ //
16017
+ // The full-screen editor is drawn by this file, so the terminal's own drag-select cannot reach it: a drag
16018
+ // lands on the alternate screen as mouse events, not as a text selection. These helpers give it the parts of
16019
+ // a text field people reach for — drag to select, then Copy / Cut / Paste / Select all from a right-click.
16020
+ //
16021
+ // Offsets are expressed as `{ row, col }` in TEXT coordinates, never as screen cells: the editor can be
16022
+ // scrolled, so a screen row means nothing once `top` moves.
16023
+
16024
+ /** The editor selection as a flat character range, or null when nothing is selected. */
16025
+ function editorSelectionRange() {
16026
+ const ed = state.editor;
16027
+ if (!ed || !ed.sel) return null;
16028
+ const ls = ed.text.split('\n');
16029
+ const toOffset = (p) => {
16030
+ let n = 0;
16031
+ for (let i = 0; i < p.row && i < ls.length; i++) n += (ls[i] || '').length + 1;
16032
+ return n + Math.max(0, Math.min((ls[p.row] || '').length, p.col));
16033
+ };
16034
+ const a = toOffset(ed.sel.anchor);
16035
+ const b = toOffset(ed.sel.head);
16036
+ if (a === b) return null;
16037
+ return { from: Math.min(a, b), to: Math.max(a, b) };
16038
+ }
16039
+
16040
+ /** The selected text, or ''. */
16041
+ function editorSelectedText() {
16042
+ const r = editorSelectionRange();
16043
+ if (!r) return '';
16044
+ return state.editor.text.slice(r.from, r.to);
16045
+ }
16046
+
16047
+ /** Drop the selection. */
16048
+ function editorClearSelection() {
16049
+ if (state.editor) state.editor.sel = null;
16050
+ }
16051
+
16052
+ /** Replace the selection (or the caret) with `text`, and put the caret after it. */
16053
+ function editorReplace(text) {
16054
+ const ed = state.editor;
16055
+ if (!ed) return;
16056
+ const r = editorSelectionRange();
16057
+ const from = r ? r.from : null;
16058
+ if (r) {
16059
+ ed.text = ed.text.slice(0, r.from) + text + ed.text.slice(r.to);
16060
+ ed.sel = null;
16061
+ } else {
16062
+ // No selection: insert at the caret. `caretRow`/`caretCol` are the single source of truth when there is
16063
+ // no selection, so convert them once rather than keeping a second offset in sync.
16064
+ const ls = ed.text.split('\n');
16065
+ let n = 0;
16066
+ for (let i = 0; i < ed.caretRow && i < ls.length; i++) n += (ls[i] || '').length + 1;
16067
+ const at = n + Math.max(0, Math.min((ls[ed.caretRow] || '').length, ed.caretCol));
16068
+ ed.text = ed.text.slice(0, at) + text + ed.text.slice(at);
16069
+ const before = ed.text.slice(0, at + text.length);
16070
+ const parts = before.split('\n');
16071
+ ed.caretRow = parts.length - 1;
16072
+ ed.caretCol = parts[parts.length - 1].length;
16073
+ void from;
16074
+ renderFrame();
16075
+ return;
16076
+ }
16077
+ const before = ed.text.slice(0, r.from + text.length);
16078
+ const parts = before.split('\n');
16079
+ ed.caretRow = parts.length - 1;
16080
+ ed.caretCol = parts[parts.length - 1].length;
16081
+ renderFrame();
16082
+ }
16083
+
16084
+ /**
16085
+ * Move the selection head to a SCREEN position during a drag.
16086
+ *
16087
+ * The pointer reports a screen row and column; the selection lives in text coordinates. The row is mapped
16088
+ * back through the editor viewport (`ed.top`) and clamped to the buffer, and the column through
16089
+ * `charIndexForVisualCol` because a wide glyph occupies two cells and would otherwise land the head to the
16090
+ * right of the character under the pointer.
16091
+ *
16092
+ * @param {number} row Screen row, 1-based.
16093
+ * @param {number} col Screen column, 1-based.
16094
+ */
16095
+ function editorDragTo(row, col) {
16096
+
16097
+ const ed = state.editor;
16098
+ if (!ed || !ed.sel) return;
16099
+ const ls = ed.text.split('\n');
16100
+ // The body starts four rows down (title, rule, hint, blank) and is scrolled by `ed.top`.
16101
+ const bodyRow = Math.max(0, (row || 1) - 1 - 4);
16102
+ const textRow = Math.max(0, Math.min(ls.length - 1, (ed.top || 0) + bodyRow));
16103
+ const textCol = charIndexForVisualCol(ls[textRow] || '', Math.max(0, (col || 1) - 1));
16104
+ const changed = ed.sel.head.row !== textRow || ed.sel.head.col !== textCol;
16105
+ if (!changed) return;
16106
+ ed.sel.head = { row: textRow, col: textCol };
16107
+ // The caret follows the drag, so the view scrolls to keep it visible on a long selection.
16108
+ ed.caretRow = textRow;
16109
+ ed.caretCol = textCol;
16110
+ renderFrame();
16111
+ }
16112
+
16113
+ /** End a drag. The selection stays; only the `dragging` flag clears. */
16114
+ function editorEndDrag() {
16115
+ if (state.editor) state.editor.dragging = false;
16116
+ }
16117
+
16118
+ /** Select the whole buffer and put the caret at its end. */
16119
+ /**
16120
+ * The selected COLUMN RANGE on text line `idx`, or null when that line has none.
16121
+ *
16122
+ * A drag is stored as two `{ row, col }` points, which the renderer cannot use directly: a selection can
16123
+ * begin on one line and end on another, so each line needs its own span. The FIRST and LAST lines are
16124
+ * partial and every line between them is selected whole.
16125
+ *
16126
+ * @param {object} ed
16127
+ * @param {number} idx Text-line index.
16128
+ * @returns {{from: number, to: number}|null}
16129
+ */
16130
+ function editorSelectAll() {
16131
+ const ed = state.editor;
16132
+ if (!ed) return;
16133
+ const ls = ed.text.split('\n');
16134
+ ed.sel = { anchor: { row: 0, col: 0 }, head: { row: ls.length - 1, col: (ls[ls.length - 1] || '').length } };
16135
+ ed.caretRow = ls.length - 1;
16136
+ ed.caretCol = (ls[ls.length - 1] || '').length;
16137
+ renderFrame();
16138
+ }
16139
+
15511
16140
  function openPanel(title, lines) {
16141
+
15512
16142
  state.panel = { title: title || '', lines: lines || [], top: 0 };
15513
16143
  state.picker = null; state.pickerQuery = ''; state.pickerCategory = null; state.form = null;
15514
16144
  state.menuOpen = false; state.menuList = []; state.menuSel = 0;
15515
16145
  renderFrame();
15516
16146
  }
15517
-
15518
16147
  // The display, as the session sees it. `runAgent` and everything it calls take this
16148
+
15519
16149
  // instead of reaching for `state` and `renderFrame` directly, which is what lets the same
15520
16150
  // code run in the web daemon where there is no terminal to draw on.
15521
16151
  const view = tuiView({
15522
16152
  // `hub` is assigned later (startWeb), so it is read through a getter rather than captured
15523
16153
  // here — a captured copy would be the null we have now, and every web push would no-op.
15524
16154
  get hub() { return hub; },
16155
+ get hub() { return hub; },
15525
16156
  renderFrame,
15526
16157
  renderSoon,
15527
16158
  addChat,
@@ -15645,6 +16276,13 @@ export async function startTUI(opts) {
15645
16276
  // because `generateTitle` is a hoisted declaration in this same scope: it is defined far
15646
16277
  // below, but the binding exists as soon as this statement runs.
15647
16278
  sessHost.makeTitle = generateTitle;
16279
+ // THE CONTEXT GAUGE, reachable from `runTurn`.
16280
+ //
16281
+ // `setCtxTokens` keeps `ctxPercent` in step with `ctxTokens`, but it is declared in THIS scope and `runTurn`
16282
+ // is module-level. An earlier attempt added it to the wrong `host` object (there are two in this file, and
16283
+ // `runTurn` receives the SessionHost instance), so the call threw `setCtxTokens is not defined` — the same
16284
+ // mistake the `makeTitle` note above describes, made again.
16285
+ sessHost.setCtxTokens = setCtxTokens;
15648
16286
  // The Goal tool writes the SAME objective field /goal does, so the status panel and the turn loop cannot
15649
16287
  // disagree about what the agent is working towards. `runTurn` reaches both through `sessHost`, which is why
15650
16288
  // the store is installed here rather than inside the tool.
@@ -16559,6 +17197,7 @@ export async function startTUI(opts) {
16559
17197
  // Keys typed while the capability probe owned stdin were handed back rather than
16560
17198
  // dropped, so the composer starts with them instead of losing them.
16561
17199
  let keyBuf = probe.rest || '';
17200
+ try { fs.appendFileSync(path.join(os.tmpdir(), "kbuf.txt"), "probe.rest=" + JSON.stringify(probe.rest) + "\n"); } catch {} // __KEYBUF_PROBE__
16562
17201
  let escTimer = null;
16563
17202
  let escRetries = 0;
16564
17203
  let confirmTimer = null;
@@ -16701,6 +17340,14 @@ export async function startTUI(opts) {
16701
17340
  const back = qs.remember && qs.remember.get(qs.index);
16702
17341
  qs.sel = back ? back.sel : 0;
16703
17342
  qs.picked = back ? new Set(back.picked) : new Set();
17343
+ // A FREE-TEXT QUESTION ARRIVES FOCUSED: the field is its only row, so leaving it un-focused would show
17344
+ // a question the user cannot answer.
17345
+ const q = qs.items[qs.index];
17346
+ if (q && q.freeText) {
17347
+ qs.editing = 'other';
17348
+ qs.editingText = qs.otherText || '';
17349
+ qs.editingCaret = qs.editingText.length;
17350
+ }
16704
17351
  return;
16705
17352
  }
16706
17353
  qs.index = total; // the Other tab
@@ -16859,6 +17506,11 @@ export async function startTUI(opts) {
16859
17506
  const t = state._lastMouse;
16860
17507
  const col = t ? Math.max(0, (t.col || 1) - 1) : 0;
16861
17508
  ed.caretCol = charIndexForVisualCol(ls[ed.caretRow] || '', col);
17509
+ // A PRESS BEGINS A DRAG. The anchor is where the selection starts; `head` follows the pointer. That
17510
+ // split is what makes the editor behave like a text field — a click places the caret, and dragging
17511
+ // from it selects. Without an anchor a drag could only ever move the caret.
17512
+ ed.sel = { anchor: { row: ed.caretRow, col: ed.caretCol }, head: { row: ed.caretRow, col: ed.caretCol } };
17513
+ ed.dragging = true;
16862
17514
  renderFrame();
16863
17515
  return true;
16864
17516
  }
@@ -17019,6 +17671,7 @@ export async function startTUI(opts) {
17019
17671
  }
17020
17672
  }
17021
17673
  function handleMouse(t) {
17674
+
17022
17675
  state._lastMouse = t;
17023
17676
  // Every branch below maps the pointer through geometry `composeFrame` produced —
17024
17677
  // `hitAt` reads the hitbox list, `mouseToCell` reads `_bodyScreenTop`. A click can
@@ -17033,6 +17686,15 @@ export async function startTUI(opts) {
17033
17686
  if (hb && dispatchHit(hb)) return;
17034
17687
  }
17035
17688
  if (state.picker || state.form || state.panel || state.editor) {
17689
+ // RIGHT-CLICK REACHES THE MENU even though an overlay owns the screen.
17690
+ //
17691
+ // A picker that came FROM a right-click moves its menu; the editor opens its own. Every other overlay
17692
+ // ignores it and falls through to the dismissal logic below, which is what it did before — but the
17693
+ // editor had no path at all, so right-clicking the full-screen editor was a no-op.
17694
+ if (t.key === 'rightclick') {
17695
+ openContextMenu(t);
17696
+ return;
17697
+ }
17036
17698
  if (t.key === 'wheelup' || t.key === 'wheeldown') handleKey(t);
17037
17699
  // Hover must still update, or a picker row never lights up: the early
17038
17700
  // return below left `state.hoverHit` stale while an overlay was open, so the
@@ -17064,6 +17726,15 @@ export async function startTUI(opts) {
17064
17726
  openContextMenu(t);
17065
17727
  return;
17066
17728
  }
17729
+ // EDITOR DRAG, INSIDE THE OVERLAY.
17730
+ //
17731
+ // The `mousemove` handler further down is only reachable when NO overlay is open, and the editor IS an
17732
+ // overlay: every pointer move landed on the catch-all `return` at the end of this branch. A press has
17733
+ // already set `dragging`, so this is where the head follows the pointer.
17734
+ if (state.editor && state.editor.dragging && t.key === 'mousemove') {
17735
+ editorDragTo(t.row, t.col);
17736
+ return;
17737
+ }
17067
17738
  if (t.key === 'mousedown' && !hitAt(t)) {
17068
17739
  dismissOverlay();
17069
17740
  renderFrame();
@@ -17168,6 +17839,16 @@ export async function startTUI(opts) {
17168
17839
  return;
17169
17840
  }
17170
17841
  if (t.key === 'mousemove') {
17842
+ // DRAGGING INSIDE THE EDITOR EXTENDS THE SELECTION.
17843
+ //
17844
+ // `state.editor.dragging` is set by the press and cleared by the release. While it is on, the pointer
17845
+ // row/column is converted to a text position and stored as the selection `head`; the anchor stays where
17846
+ // the press landed. A pointer outside the text area clamps to the nearest line rather than dropping the
17847
+ // drag, which is what lets a selection be dragged past the last visible row.
17848
+ if (state.editor && state.editor.dragging) {
17849
+ editorDragTo(t.row, t.col);
17850
+ return;
17851
+ }
17171
17852
  if (state.sbDrag) { scrollFromThumb(mouseToCell(t).rowInBody); return; }
17172
17853
  // Dragging the todo rule resizes the panel: moving UP shows more rows.
17173
17854
  if (state.todoResizeDrag) {
@@ -17227,6 +17908,9 @@ export async function startTUI(opts) {
17227
17908
  return;
17228
17909
  }
17229
17910
  if (t.key === 'mouseup') {
17911
+ // THE RELEASE ENDS ANY EDITOR DRAG. The selection stays on screen — only `dragging` clears, so the next
17912
+ // press starts a new anchor rather than extending the old one.
17913
+ if (state.editor && state.editor.dragging) { editorEndDrag(); return; }
17230
17914
  state._mouseDownPos = null;
17231
17915
  state._composerMouseDown = null;
17232
17916
  if (state.todoResizeDrag) {
@@ -17502,6 +18186,53 @@ export async function startTUI(opts) {
17502
18186
  }
17503
18187
 
17504
18188
  function openContextMenu(t) {
18189
+ // THE EDITOR HAS ITS OWN MENU.
18190
+ //
18191
+ // The full-screen editor is not the composer and not the transcript: its Paste inserts at ITS caret, and
18192
+ // its Copy reads ITS selection. Offering the transcript actions here would act on something the user
18193
+ // cannot see — the editor owns the whole screen — so this branch returns before those are built.
18194
+ if (state.editor) {
18195
+ const ed = state.editor;
18196
+ const hasEditorSel = !!(ed.sel && editorSelectionRange());
18197
+ const items = [
18198
+ { label: 'Copy', sub: hasEditorSel ? 'copy the selected text' : 'nothing selected', action: 'editor-copy', primary: hasEditorSel },
18199
+ { label: 'Cut', sub: hasEditorSel ? 'cut the selected text' : 'nothing selected', action: 'editor-cut' },
18200
+ { label: 'Paste', sub: 'paste at the caret, replacing any selection', action: 'editor-paste', primary: !hasEditorSel },
18201
+ { label: 'Select all', sub: 'select the whole text', action: 'editor-select-all' },
18202
+ ];
18203
+ openPicker({
18204
+ title: 'Edit',
18205
+ items,
18206
+ searchable: false,
18207
+ anchor: { row: t.row, col: t.col },
18208
+ hint: '↑↓ navigate · Enter run · Esc cancel',
18209
+ onPick: (it) => {
18210
+ if (it.action === 'editor-copy') {
18211
+ const text = editorSelectedText();
18212
+ if (text) copyToClipboard(text); else notice('Nothing selected', 'error');
18213
+ }
18214
+ else if (it.action === 'editor-cut') {
18215
+ const text = editorSelectedText();
18216
+ if (!text) { notice('Nothing selected', 'error'); return true; }
18217
+ copyToClipboard(text);
18218
+ editorReplace('');
18219
+ }
18220
+ else if (it.action === 'editor-paste') {
18221
+ const clip = readClipboardContentAsync();
18222
+ void clip.then((r) => {
18223
+ const text = r && r.text ? String(r.text) : '';
18224
+ if (!text) { notice('Clipboard is empty', 'error'); return; }
18225
+ editorReplace(text);
18226
+ });
18227
+ }
18228
+ else if (it.action === 'editor-select-all') {
18229
+ editorSelectAll();
18230
+ }
18231
+ return true;
18232
+ },
18233
+ });
18234
+ return;
18235
+ }
17505
18236
  const hasSel = !!(state.selection && state.selection.anchor && state.selection.head);
17506
18237
  const hasComposerSel = !!(state.composerSel && state.composerSel.anchor !== state.composerSel.head);
17507
18238
  const items = [];
@@ -17600,6 +18331,7 @@ export async function startTUI(opts) {
17600
18331
  const repeatState = { key: null, at: 0, run: 0 };
17601
18332
 
17602
18333
  function handleKey(t) {
18334
+ try { fs.appendFileSync(path.join(os.tmpdir(), "kbuf.txt"), "key=" + JSON.stringify(t && (t.ch || t.key)) + " input=" + JSON.stringify(state.input) + "\n"); } catch {} // __KEYBUF_PROBE__
17603
18335
  // A KITTY GRAPHICS REPLY IS NOT A KEYSTROKE. It is the terminal acknowledging an upload (`G i=31;OK`) or
17604
18336
  // reporting that it dropped an image. The token exists so the bytes are CONSUMED rather than typed into the
17605
18337
  // composer; there is nothing to act on yet, because a failed upload is handled by placing the formula as
@@ -17965,7 +18697,17 @@ export async function startTUI(opts) {
17965
18697
  .filter(Boolean);
17966
18698
  const typed = (qs.otherText || '').trim();
17967
18699
  if (typed) labels.push(typed);
17968
- if (labels.length) qs.answers[cur.question] = labels.join(', ');
18700
+ // NOTHING PICKED IS NOT AN ANSWER — say so instead of advancing.
18701
+ //
18702
+ // `advance()` ran regardless, so pressing Enter on a multi-select with no boxes ticked moved to the
18703
+ // next question and left this one unanswered. The user could not tell "submitted nothing" from
18704
+ // "submitted", and the skipped question came back at the end looking like a fresh prompt.
18705
+ if (!labels.length) {
18706
+ appErr('Pick at least one option with Space, or type an answer under Other.');
18707
+ renderFrame();
18708
+ return;
18709
+ }
18710
+ qs.answers[cur.question] = labels.join(', ');
17969
18711
  advance();
17970
18712
  return;
17971
18713
  }
@@ -19248,6 +19990,7 @@ function wheelStep(state, family) {
19248
19990
  // A NEW TURN means a new chance for the goal to restart it. The counter exists so one turn restarts the
19249
19991
  // goal at most once; without resetting it here, the second turn of a goal would never continue.
19250
19992
  state._goalTurns = 0;
19993
+ state._turnFailed = false; // a fresh turn has not failed yet
19251
19994
  state.historyIdx = -1;
19252
19995
  // The draft belongs to the history walk that just ended; the next ↑ starts fresh.
19253
19996
  state.draftInput = '';
@@ -19311,7 +20054,8 @@ function wheelStep(state, family) {
19311
20054
 
19312
20055
  if (state.history[state.history.length - 1] !== text) {
19313
20056
  state.history.push(text);
19314
- saveHistory(state.history); // persist so ↑ survives a restart
20057
+ // Persisted under THIS session's id, so ↑ in another conversation cannot offer it.
20058
+ saveHistory(state.history, session && session.id);
19315
20059
  }
19316
20060
  // Shell passthrough. The MODE is authoritative (the `!` never entered the
19317
20061
  // buffer); a leading `!` in the text is still honoured so a pasted line like
@@ -19956,9 +20700,6 @@ function wheelStep(state, family) {
19956
20700
  // this the caller and the command disagreed about which session was current — every later
19957
20701
  // save wrote the old object, still stamped with the directory it was moved away from.
19958
20702
  replaceSession: (s) => { if (!s) return; session = s; state.session = s; if (s.workspace) state.workspace = s.workspace; writeWindowTitle(session, stdout); },
19959
- // Rebinds the closure's session. /move replaces it, and without this the caller and the
19960
- // command disagreed about which session was current — every later save wrote the old one.
19961
- replaceSession: (s) => { if (s) session = s; writeWindowTitle(session, stdout); },
19962
20703
  getMessages: () => (session && session.messages ? session.messages : []),
19963
20704
  clearSession: () => { if (session) session.messages = []; if (state) { state.ctxTokens = 0; state.ctxPercent = 0; } renderFrame(); },
19964
20705
  getConfig: (key) => (cfg && key != null ? cfg[key] : undefined),