@hncode/hncode 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -37
- package/src/agent.js +35 -4
- package/src/config.js +19 -10
- package/src/row-cache.js +31 -2
- package/src/settings-schema.js +17 -2
- package/src/team/team.js +7 -4
- package/src/tools/ask-user-question.js +18 -3
- package/src/tools/edit.js +44 -74
- package/src/tools/web-search.js +205 -139
- package/src/tui.js +849 -108
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
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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 >
|
|
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
|
|
3345
|
-
return Math.max(
|
|
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
|
-
//
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
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(
|
|
3441
|
+
out.push(rule('╭', '┬', '╮'));
|
|
3359
3442
|
oneRow(header, true);
|
|
3360
|
-
out.push(
|
|
3443
|
+
out.push(rule('├', '┼', '┤'));
|
|
3361
3444
|
for (const r of body) oneRow(r, false);
|
|
3362
|
-
out.push(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
5989
|
-
|
|
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.
|
|
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
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
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,14 +6650,15 @@ 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
|
-
|
|
6653
|
+
}
|
|
6654
|
+
if (!cur.freeText && qs.editing === 'other' && showHint) {
|
|
6417
6655
|
const label = OTHER_LABEL + ': ';
|
|
6418
6656
|
const text = String(qs.editingText || '');
|
|
6419
6657
|
const ec = Math.max(0, Math.min(text.length, qs.editingCaret == null ? text.length : qs.editingCaret));
|
|
6420
6658
|
caretInBox = box.length;
|
|
6421
6659
|
caretColInBox = visualCol(label) + visualCol(text.slice(0, ec));
|
|
6422
6660
|
box.push(boxRow(col(label, C.gray) + col(text, C.white)));
|
|
6423
|
-
} else if (showHint) {
|
|
6661
|
+
} else if (!cur.freeText && showHint) {
|
|
6424
6662
|
const hint = cur.multiSelect
|
|
6425
6663
|
? '↑↓ move · Space toggle · Enter confirm · Esc dismiss'
|
|
6426
6664
|
: '↑↓ move · Enter select · Esc dismiss';
|
|
@@ -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
|
-
//
|
|
7585
|
-
// in
|
|
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
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
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
|
-
|
|
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
|
|
8383
|
-
{ label: 'Yolo', sub: '
|
|
8384
|
-
{ label: 'Auto', sub: '
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
10062
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13936
|
-
//
|
|
13937
|
-
// estimate
|
|
13938
|
-
|
|
13939
|
-
|
|
13940
|
-
|
|
13941
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
14560
|
-
|
|
14561
|
-
|
|
14562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|