@hncode/hncode 0.3.3 → 0.3.5
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/LICENSE +131 -674
- package/README.md +5 -4
- package/README_CN.md +5 -4
- package/package.json +2 -2
- package/src/agent-task.js +78 -0
- package/src/agent.js +75 -45
- package/src/config.js +63 -19
- package/src/git.js +170 -1
- package/src/index.js +27 -13
- package/src/llm.js +7 -3
- package/src/mcp.js +21 -13
- package/src/plugin.js +10 -8
- package/src/term.js +84 -46
- package/src/tui.js +659 -186
- package/src/tui.js.bak_20260921_191527 +9203 -0
- package/src/update.js +35 -14
package/src/tui.js
CHANGED
|
@@ -34,7 +34,7 @@ import * as upd from './update.js';
|
|
|
34
34
|
import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
|
|
35
35
|
import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
|
|
36
36
|
import { renderSwarmProgress } from './swarm-progress.js';
|
|
37
|
-
import { sortedTasks, getTask, settleTask, STATUS_LABEL } from './agent-task.js';
|
|
37
|
+
import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
|
|
38
38
|
import { localVersion } from './version.js';
|
|
39
39
|
import { listSkills, readSkill, importSkill, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
|
|
40
40
|
import { loadHooks, describeHooks, runShellHooks, HOOK_EVENTS } from './hooks.js';
|
|
@@ -97,11 +97,18 @@ export const COMMANDS = [
|
|
|
97
97
|
{ name: 'branch', desc: 'List, create, or switch git branches', priority: 62, argumentHint: '[list | <name> | -c <name>]' },
|
|
98
98
|
{ name: 'worktree', desc: 'List, add, or remove git worktrees for parallel work', priority: 60, argumentHint: '[list | add <dir> [branch] | remove <dir>]' },
|
|
99
99
|
{ name: 'pr', desc: 'Show the URL to open a pull request for the current branch', priority: 60, argumentHint: '[base]' },
|
|
100
|
+
{ name: 'add', aliases: ['stage'], desc: 'Stage changes (all by default, or given paths)', priority: 62, argumentHint: '[<path> ...]' },
|
|
101
|
+
{ name: 'diff', desc: 'Show uncommitted changes', priority: 62, argumentHint: '[--staged] [<path>]' },
|
|
102
|
+
{ name: 'log', desc: 'Show recent commits', priority: 62, argumentHint: '[count] [<path>]' },
|
|
103
|
+
{ name: 'push', desc: 'Push the current branch (asks first)', priority: 62, argumentHint: '[branch] [-f]' },
|
|
104
|
+
{ name: 'stash', desc: 'Stash changes: list / push / pop', priority: 60, argumentHint: '[list | push [msg] | pop]' },
|
|
105
|
+
{ name: 'rebase', desc: 'Rebase the current branch onto another (asks first)', priority: 60, argumentHint: '<branch>' },
|
|
100
106
|
{ name: 'cache', desc: 'Toggle prompt caching for long stable prefixes (saves input tokens)', priority: 58, argumentHint: '[on|off]' },
|
|
101
107
|
{ name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
|
|
102
108
|
{ name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
|
|
103
109
|
{ name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
|
|
104
110
|
{ name: 'auto-update', desc: 'Toggle automatic background updates (check at startup + every 30 min)', priority: 40, argumentHint: '[on|off]' },
|
|
111
|
+
{ name: 'auto-compact', aliases: ['autocompact'], desc: 'Toggle automatic context compaction at 85% of the window', priority: 40, argumentHint: '[on|off]' },
|
|
105
112
|
{ name: 'version', desc: 'Show version information', priority: 20 },
|
|
106
113
|
{ name: 'exit', aliases: ['quit', 'q'], desc: 'Exit the application', priority: 20 },
|
|
107
114
|
].sort((a, b) => (b.priority || 0) - (a.priority || 0) || a.name.localeCompare(b.name));
|
|
@@ -254,7 +261,7 @@ export const TIPS = [
|
|
|
254
261
|
'/yolo auto-approves anything inside the workspace — risky paths still ask',
|
|
255
262
|
'/auto never interrupts you; everything runs and is decided automatically',
|
|
256
263
|
'Shift+Arrow selects text in the composer; Backspace or Delete removes it',
|
|
257
|
-
'
|
|
264
|
+
'Shift+Enter inserts a newline without sending the message',
|
|
258
265
|
'Ctrl+T expands or collapses the todo panel',
|
|
259
266
|
'Ctrl+O expands or collapses tool output, thinking blocks and Edit diffs',
|
|
260
267
|
'Ctrl+B moves a long-running foreground Bash command to the background',
|
|
@@ -670,17 +677,25 @@ function wrapWords(str, width) {
|
|
|
670
677
|
width = Math.max(1, width | 0);
|
|
671
678
|
const out = [];
|
|
672
679
|
let line = '';
|
|
680
|
+
// Running width of `line`. It used to be recomputed with
|
|
681
|
+
// `visualCol(line + ' ' + w)` on EVERY word, which is O(line) per word and so
|
|
682
|
+
// O(n²) per paragraph — a single long line (a streamed paragraph, a wide table
|
|
683
|
+
// row) cost ~46ms at 20KB and froze the UI on every markdown chunk. Widths are
|
|
684
|
+
// additive for the plain text this receives (no ANSI is present until
|
|
685
|
+
// inlineMarkdown runs later), so accumulate instead of re-measuring.
|
|
686
|
+
let lineW = 0;
|
|
673
687
|
// Expand tabs first: visualCol counts '\t' as 0 columns but the terminal
|
|
674
688
|
// advances to the next tab stop, which made the row wider than computed and
|
|
675
689
|
// wrapped it onto the following line.
|
|
676
690
|
const words = expandTabs(str).split(' ');
|
|
677
|
-
for (
|
|
691
|
+
for (const w of words) {
|
|
692
|
+
const wW = visualCol(w);
|
|
678
693
|
// A word longer than the line must be hard-split. This MUST measure visual
|
|
679
694
|
// columns (not string length): CJK characters occupy two cells, so slicing
|
|
680
695
|
// by `.length` would emit lines twice as wide as the box and the terminal
|
|
681
696
|
// would wrap them onto the next row, corrupting the layout.
|
|
682
|
-
if (
|
|
683
|
-
if (line) { out.push(line); line = ''; }
|
|
697
|
+
if (wW > width) {
|
|
698
|
+
if (line) { out.push(line); line = ''; lineW = 0; }
|
|
684
699
|
let chunk = '';
|
|
685
700
|
let cw = 0;
|
|
686
701
|
for (const ch of w) {
|
|
@@ -688,12 +703,18 @@ function wrapWords(str, width) {
|
|
|
688
703
|
if (cw + chw > width) { out.push(chunk); chunk = ''; cw = 0; }
|
|
689
704
|
chunk += ch; cw += chw;
|
|
690
705
|
}
|
|
691
|
-
if (chunk) line = chunk;
|
|
706
|
+
if (chunk) { line = chunk; lineW = cw; }
|
|
692
707
|
continue;
|
|
693
708
|
}
|
|
694
|
-
const
|
|
695
|
-
if (
|
|
696
|
-
|
|
709
|
+
const sep = line ? 1 : 0; // the single space we would add
|
|
710
|
+
if (lineW + sep + wW <= width) {
|
|
711
|
+
line = line ? line + ' ' + w : w;
|
|
712
|
+
lineW += sep + wW;
|
|
713
|
+
} else {
|
|
714
|
+
if (line) out.push(line);
|
|
715
|
+
line = w;
|
|
716
|
+
lineW = wW;
|
|
717
|
+
}
|
|
697
718
|
}
|
|
698
719
|
if (line) out.push(line);
|
|
699
720
|
else if (out.length === 0) out.push('');
|
|
@@ -1561,6 +1582,37 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
|
|
|
1561
1582
|
return out;
|
|
1562
1583
|
}
|
|
1563
1584
|
|
|
1585
|
+
// A BACKGROUND TASK lifecycle card (kimi-style):
|
|
1586
|
+
// ● agent task completed in background (review the diff)
|
|
1587
|
+
// ✗ bash task failed in background (npm test · exit 1)
|
|
1588
|
+
// The bullet is `●` normally and `✗` for a failure; the colour follows the
|
|
1589
|
+
// phase. The task's conclusion sits underneath, indented, and is capped like
|
|
1590
|
+
// a tool result so one chatty subagent cannot flood the transcript.
|
|
1591
|
+
if (msg.role === 'bg_task') {
|
|
1592
|
+
const card = msg.card || { phase: 'completed', headline: 'background task finished', detail: undefined };
|
|
1593
|
+
const tone = card.phase === 'started' ? C.cyan : card.phase === 'completed' ? C.green : C.red;
|
|
1594
|
+
const bullet = card.phase === 'failed' ? '✗ ' : '● ';
|
|
1595
|
+
const out = [];
|
|
1596
|
+
const detail = card.detail ? col(` (${card.detail})`, C.gray) : '';
|
|
1597
|
+
out.push({ text: col(bullet, tone) + col(card.headline, tone) + detail, ind: '', raw: true });
|
|
1598
|
+
const body = String(msg.text || '').trim();
|
|
1599
|
+
if (body) {
|
|
1600
|
+
const indent = ' ';
|
|
1601
|
+
const avail = Math.max(1, width - visualCol(indent));
|
|
1602
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
1603
|
+
const MAX = expanded ? 200 : 6;
|
|
1604
|
+
for (const ln of lines.slice(0, MAX)) {
|
|
1605
|
+
for (const w of (ln === '' ? [''] : wrapWords(ln, avail))) {
|
|
1606
|
+
out.push({ text: col(w, C.gray), ind: indent, raw: true });
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (lines.length > MAX) {
|
|
1610
|
+
out.push({ text: col(`… ${lines.length - MAX} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return out;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1564
1616
|
// Tool output: a single "↳ " marker on the first line, then the output
|
|
1565
1617
|
// indented by 2 columns. Long output is capped (kimi caps result previews
|
|
1566
1618
|
// too) so one command cannot flood the whole transcript.
|
|
@@ -2171,14 +2223,34 @@ export function argStreamLineCount(st, key) {
|
|
|
2171
2223
|
// streamed (assistant text and a Write's content only ever grow), and is written
|
|
2172
2224
|
// once when a turn completes. A large equal-length replacement does not occur on
|
|
2173
2225
|
// these fields — the diff/content that can change arbitrarily live elsewhere.
|
|
2226
|
+
// Fingerprint memo. `fp()` used to build `${len}:${text}` for every field of
|
|
2227
|
+
// every message on EVERY frame — for a 3.4k-message transcript the key strings
|
|
2228
|
+
// alone measured ~60ms per frame, which starved the 80ms animation tick and
|
|
2229
|
+
// made the startup animation visibly stall. Text fields change rarely, so the
|
|
2230
|
+
// fingerprint is memoised per string VALUE: the same immutable string returns
|
|
2231
|
+
// the same fingerprint without re-measuring. The map is bounded so a long
|
|
2232
|
+
// session with ever-growing streamed text cannot grow it without limit.
|
|
2174
2233
|
const FP_VERBATIM_MAX = 4096;
|
|
2234
|
+
const FP_CACHE_MAX = 8192;
|
|
2235
|
+
const fpCache = new Map();
|
|
2175
2236
|
function fp(s) {
|
|
2176
2237
|
if (s == null) return '0';
|
|
2177
|
-
const str = String(s);
|
|
2238
|
+
const str = typeof s === 'string' ? s : String(s);
|
|
2239
|
+
const hit = fpCache.get(str);
|
|
2240
|
+
if (hit !== undefined) return hit;
|
|
2178
2241
|
const n = str.length;
|
|
2179
|
-
|
|
2242
|
+
// Short strings keep the text inline (cheap and collision-free); long ones use
|
|
2243
|
+
// the length alone, as before.
|
|
2244
|
+
const out = n <= FP_VERBATIM_MAX ? n + ':' + str : n + ':';
|
|
2245
|
+
if (fpCache.size >= FP_CACHE_MAX) {
|
|
2246
|
+
const oldest = fpCache.keys().next().value;
|
|
2247
|
+
if (oldest !== undefined) fpCache.delete(oldest);
|
|
2248
|
+
}
|
|
2249
|
+
fpCache.set(str, out);
|
|
2250
|
+
return out;
|
|
2180
2251
|
}
|
|
2181
2252
|
|
|
2253
|
+
|
|
2182
2254
|
// Fingerprint of a tool-args object for the render-cache key. `JSON.stringify`
|
|
2183
2255
|
// was used here and stringified EVERY argument — including a streaming Write's
|
|
2184
2256
|
// whole file content — on every frame. The rendered tool line only shows the key
|
|
@@ -2196,82 +2268,158 @@ function fpArgs(args) {
|
|
|
2196
2268
|
return out;
|
|
2197
2269
|
}
|
|
2198
2270
|
|
|
2199
|
-
function renderChatLines(state, w) {
|
|
2271
|
+
export function renderChatLines(state, w, viewport) {
|
|
2200
2272
|
const out = [];
|
|
2201
2273
|
const expanded = !!state.expanded;
|
|
2202
|
-
// One blank column on the left of the whole transcript, so the markers (❯, ●,
|
|
2203
|
-
// ↳) do not sit flush against the screen edge.
|
|
2204
2274
|
const PAD = ' ';
|
|
2205
2275
|
const padW = visualCol(PAD);
|
|
2206
2276
|
const innerW = Math.max(1, w - padW);
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2277
|
+
const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2278
|
+
const chat = state.chat;
|
|
2279
|
+
const n = chat.length;
|
|
2280
|
+
|
|
2281
|
+
// Pass 1: exact row count per message (and the total). Every message's cache
|
|
2282
|
+
// is validated/rebuilt here so the counts are exact; only the STRING assembly
|
|
2283
|
+
// is limited to the visible window.
|
|
2284
|
+
const rowStart = new Int32Array(n);
|
|
2285
|
+
const msgLen = new Int32Array(n);
|
|
2286
|
+
let cursor = 0;
|
|
2287
|
+
for (let mi = 0; mi < n; mi++) {
|
|
2288
|
+
const m = chat[mi];
|
|
2289
|
+
const border = m.role === 'user' || m.role === 'queued' || m.role === 'bash';
|
|
2290
|
+
const ww = border ? Math.max(1, Math.min(1, innerW - 4)) : innerW; // placeholder
|
|
2291
|
+
void ww;
|
|
2292
|
+
rowStart[mi] = cursor;
|
|
2293
|
+
// Reuse/minimal layout for the row count: same width/border arithmetic as
|
|
2294
|
+
// the layout path below. We need boxText for bordered messages.
|
|
2295
|
+
const boxInner = Math.max(1, innerW - 4);
|
|
2296
|
+
const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
|
|
2297
|
+
const rowWidth = border ? boxText : innerW;
|
|
2298
|
+
const cached = m._cache;
|
|
2299
|
+
const valid = cached
|
|
2300
|
+
&& cached.rowW === rowWidth && cached.expanded === expanded
|
|
2301
|
+
&& cached.textRef === m.text && cached.streamRef === m.streamContent
|
|
2302
|
+
&& cached.liveRef === m.liveOutput && cached.failed === m.failed
|
|
2303
|
+
&& cached.diffRef === m.diff && cached.countsRef === m._diffCounts
|
|
2304
|
+
&& cached.keyArgs === (m.toolArgs ? fpArgs(m.toolArgs) : '0')
|
|
2305
|
+
&& cached.spinKey === (m.role === 'tool' && m.pending ? (state.spin || 0) : 0)
|
|
2306
|
+
&& cached.isPending === (m.pending ? 1 : 0);
|
|
2307
|
+
if (valid) {
|
|
2308
|
+
msgLen[mi] = cached.rows.length + (border ? 2 : 0);
|
|
2309
|
+
} else {
|
|
2310
|
+
const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart);
|
|
2311
|
+
const wrapped = rows.map(rowToLine);
|
|
2312
|
+
const spinKey2 = (m.role === 'tool' && m.pending) ? (state.spin || 0) : 0;
|
|
2313
|
+
const isPending2 = m.pending ? 1 : 0;
|
|
2314
|
+
const cc = {
|
|
2315
|
+
rowW: rowWidth, expanded, spinKey: spinKey2, isPending: isPending2,
|
|
2316
|
+
textRef: m.text, streamRef: m.streamContent, liveRef: m.liveOutput,
|
|
2317
|
+
failed: m.failed, diffRef: m.diff, countsRef: m._diffCounts,
|
|
2318
|
+
keyArgs: m.toolArgs ? fpArgs(m.toolArgs) : '0',
|
|
2319
|
+
rows: wrapped, spinRows: null,
|
|
2320
|
+
};
|
|
2321
|
+
for (let i = 0; i < wrapped.length; i++)
|
|
2322
|
+
if (wrapped[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
|
|
2323
|
+
if (!cc.spinRows) cc.spinRows = new Set();
|
|
2324
|
+
cc.spinRows.add(i);
|
|
2325
|
+
}
|
|
2326
|
+
m._cache = cc;
|
|
2327
|
+
msgLen[mi] = wrapped.length + (border ? 2 : 0);
|
|
2328
|
+
}
|
|
2329
|
+
cursor += msgLen[mi];
|
|
2330
|
+
}
|
|
2331
|
+
const total = cursor;
|
|
2332
|
+
state._chatTotal = total;
|
|
2333
|
+
|
|
2334
|
+
// Visible window: [total - bodyH - scroll, total - scroll), with a small
|
|
2335
|
+
// margin above so a message crossing the boundary renders whole. When no
|
|
2336
|
+
// viewport is given, render everything (full render).
|
|
2337
|
+
let visibleFrom = 0, visibleTo = -1;
|
|
2338
|
+
if (viewport && Number.isFinite(viewport.bodyH) && viewport.bodyH > 0) {
|
|
2339
|
+
const bodyH = viewport.bodyH;
|
|
2340
|
+
// Clamp scroll against the total we just computed, so a stale scroll value
|
|
2341
|
+
// cannot push the window past the end (missing the tail).
|
|
2342
|
+
const maxScroll = Math.max(0, total - bodyH);
|
|
2343
|
+
const scroll = Math.min(maxScroll, Math.max(0, viewport.scroll || 0));
|
|
2344
|
+
const end = total - scroll;
|
|
2345
|
+
const start = end - bodyH;
|
|
2346
|
+
visibleFrom = Math.max(0, start - 12);
|
|
2347
|
+
visibleTo = Math.min(total, end + 12);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Pass 2: assemble final strings, but only for messages that overlap the
|
|
2351
|
+
// visible range (or the whole transcript when no viewport).
|
|
2352
|
+
cursor = 0;
|
|
2353
|
+
for (let mi = 0; mi < n; mi++) {
|
|
2354
|
+
const msg = chat[mi];
|
|
2213
2355
|
const bordered = msg.role === 'user' || msg.role === 'queued' || msg.role === 'bash';
|
|
2214
|
-
// A bash-mode echo uses the violet shellMode frame — same hue as kimi.
|
|
2215
2356
|
const boxColor = msg.role === 'bash' ? C.shellMode : C.border;
|
|
2216
|
-
|
|
2217
|
-
// has a 1-column margin (matching the left margin from `PAD`).
|
|
2218
|
-
const boxW = innerW - 2; // total box width, including both │
|
|
2219
|
-
const boxInner = Math.max(1, boxW - 2); // span the two │s occupy
|
|
2220
|
-
// One column of padding inside each wall, mirroring the composer box.
|
|
2221
|
-
const boxPad = Math.min(1, Math.max(0, boxInner - 1));
|
|
2222
|
-
const boxText = Math.max(1, boxInner - boxPad * 2);
|
|
2223
|
-
const topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
|
|
2224
|
-
const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
|
|
2225
|
-
// A box always needs its TOP border — including when the message is the first
|
|
2226
|
-
// row of the transcript. (The earlier `out.length > 0` guard came from the
|
|
2227
|
-
// rule-only version, where a leading divider looked wrong; a box without a
|
|
2228
|
-
// top is simply broken.) For adjacent bordered messages the previous box's
|
|
2229
|
-
// bottom already serves as this one's top, so skip the duplicate.
|
|
2230
|
-
if (bordered && out[out.length - 1] !== topRule) out.push(topRule);
|
|
2231
|
-
// The cache key MUST include everything messageLines() reads. toolArgs was
|
|
2232
|
-
// missing, so a tool row rendered at `tool_start` (args still {}) kept its
|
|
2233
|
-
// stale "Using Bash" line even after `tool_use` delivered the command —
|
|
2234
|
-
// the row only appeared once some OTHER keyed field changed. liveOutput is
|
|
2235
|
-
// keyed because it grows while a command runs; `failed` flips the bullet
|
|
2236
|
-
// and the result colour red once the tool reports a non-zero exit.
|
|
2237
|
-
// The row WIDTH is keyed (not `w`) because the rows are laid out to it:
|
|
2238
|
-
// `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
|
|
2239
|
-
const rowW = bordered ? boxText : innerW;
|
|
2240
|
-
// The cache key MUST include everything messageLines() reads. `spin` is
|
|
2241
|
-
// keyed ONLY for a pending TOOL row: formatToolLine sweeps the tool name's
|
|
2242
|
-
// colour with it. The pending THINKING row deliberately does not key it —
|
|
2243
|
-
// its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
|
|
2244
|
-
// the 80ms tick no longer re-wraps the reasoning preview every frame.
|
|
2245
|
-
const spinKey = (msg.role === 'tool' && msg.pending) ? (state.spin || 0) : 0;
|
|
2246
|
-
const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${fp(msg.text)}\u0000${fp(msg.streamContent)}`
|
|
2247
|
-
+ `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${fpArgs(msg.toolArgs)}`
|
|
2248
|
-
+ `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
|
|
2249
|
-
let cached = msg._cache;
|
|
2250
|
-
if (!cached || cached.key !== key) {
|
|
2251
|
-
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin, state.pulseStart);
|
|
2252
|
-
cached = { key, rows: rows.map(rowToLine) };
|
|
2253
|
-
msg._cache = cached;
|
|
2254
|
-
}
|
|
2255
|
-
// Swap the live spinner frame into any row that carries the placeholder.
|
|
2256
|
-
// Done AFTER the cache, so the glyph animates without invalidating it.
|
|
2257
|
-
const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2258
|
-
const live = (r) => (r.indexOf(SPIN_PLACEHOLDER) >= 0 ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
|
|
2259
|
-
// The margin is added here (outside the cache) so it is not baked into the
|
|
2260
|
-
// cached rows. A bordered message also gets its side walls, laid out to the
|
|
2261
|
-
// box's inner width so it lines up with the composer exactly.
|
|
2357
|
+
let topRule = null, botRule = null, boxPad = 1, boxText = innerW;
|
|
2262
2358
|
if (bordered) {
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
2359
|
+
const boxW = innerW - 2;
|
|
2360
|
+
const boxInner = Math.max(1, boxW - 2);
|
|
2361
|
+
boxPad = Math.min(1, Math.max(0, boxInner - 1));
|
|
2362
|
+
boxText = Math.max(1, boxInner - boxPad * 2);
|
|
2363
|
+
topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
|
|
2364
|
+
botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
|
|
2365
|
+
}
|
|
2366
|
+
const cc = msg._cache || { rows: [] };
|
|
2367
|
+
// Shared wall with the previous bordered message (its bottom rule is ours).
|
|
2368
|
+
let shared = false;
|
|
2369
|
+
if (bordered && mi > 0) {
|
|
2370
|
+
const prev = chat[mi - 1];
|
|
2371
|
+
shared = (prev.role === 'user' || prev.role === 'queued' || prev.role === 'bash')
|
|
2372
|
+
&& rowStart[mi - 1] + msgLen[mi - 1] === cursor;
|
|
2373
|
+
}
|
|
2374
|
+
const len = msgLen[mi];
|
|
2375
|
+
const visible = visibleTo < 0 || (cursor + len > visibleFrom && cursor < visibleTo);
|
|
2376
|
+
if (visible) {
|
|
2377
|
+
const spinRows = cc.spinRows;
|
|
2378
|
+
const liveAt = (r, i) => (spinRows && spinRows.has(i) ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
|
|
2379
|
+
if (bordered && !shared) out.push(topRule);
|
|
2380
|
+
if (bordered) {
|
|
2381
|
+
const pad = ' '.repeat(boxPad);
|
|
2382
|
+
const wall = col('│', boxColor);
|
|
2383
|
+
for (let i = 0; i < cc.rows.length; i++)
|
|
2384
|
+
out.push(PAD + wall + pad + fitAnsi(liveAt(cc.rows[i], i), boxText) + pad + wall);
|
|
2385
|
+
} else {
|
|
2386
|
+
for (let i = 0; i < cc.rows.length; i++) out.push(PAD + liveAt(cc.rows[i], i));
|
|
2266
2387
|
}
|
|
2388
|
+
if (bordered) out.push(botRule);
|
|
2267
2389
|
} else {
|
|
2268
|
-
for (
|
|
2390
|
+
for (let i = 0; i < len; i++) out.push('');
|
|
2269
2391
|
}
|
|
2270
|
-
|
|
2392
|
+
cursor += len;
|
|
2271
2393
|
}
|
|
2272
2394
|
return out;
|
|
2273
2395
|
}
|
|
2274
2396
|
|
|
2397
|
+
// ---- scroll anchoring --------------------------------------------------------
|
|
2398
|
+
// Keep a scrolled-up viewport pinned to the SAME content while new rows arrive.
|
|
2399
|
+
//
|
|
2400
|
+
// `state.scroll` counts rows up from the bottom, so when the transcript grows by
|
|
2401
|
+
// N rows the offset must grow by N as well — otherwise the new rows push the
|
|
2402
|
+
// content the user is reading off the top of the screen (the "it scrolls on its
|
|
2403
|
+
// own while the agent is talking" bug: measured as the viewed line moving up one
|
|
2404
|
+
// row per streamed chunk).
|
|
2405
|
+
//
|
|
2406
|
+
// The subtlety is the BASELINE. The previous version returned early while the
|
|
2407
|
+
// view was pinned (`scroll === 0`) and only recorded `_anchorRows` otherwise, so
|
|
2408
|
+
// during a turn the baseline went stale. The first append after the user
|
|
2409
|
+
// scrolled up then found `prev == null` (or an ancient value), skipped the
|
|
2410
|
+
// compensation, and the view drifted. Recording the baseline on EVERY call —
|
|
2411
|
+
// pinned or not — makes `rows - prev` always "rows added since the last paint".
|
|
2412
|
+
//
|
|
2413
|
+
// Pure: takes the state and the current chat row count, returns the new `scroll`.
|
|
2414
|
+
export function anchorScrollNext(state, rows) {
|
|
2415
|
+
const prev = state._anchorRows;
|
|
2416
|
+
state._anchorRows = rows;
|
|
2417
|
+
if ((state.scroll || 0) === 0) return 0; // pinned: keep following the tail
|
|
2418
|
+
if (prev != null && rows > prev) return (state.scroll || 0) + (rows - prev);
|
|
2419
|
+
return state.scroll || 0;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
|
|
2275
2423
|
// Serialise a Line (array of spans) to an ANSI string.
|
|
2276
2424
|
function lineToString(line) {
|
|
2277
2425
|
let s = '';
|
|
@@ -2607,11 +2755,22 @@ export function refreshGitInfo(state, opts = {}) {
|
|
|
2607
2755
|
if (!force && state.running) return;
|
|
2608
2756
|
state._gitCwd = cwd;
|
|
2609
2757
|
state._gitAt = now;
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2758
|
+
// ASYNC: does not block the caller. On the startup path this is what lets the
|
|
2759
|
+
// first frame draw immediately — the badge appears a moment later when git
|
|
2760
|
+
// answers, instead of freezing the TUI behind four spawnSync calls. `seq`
|
|
2761
|
+
// discards a stale reply if another refresh started while this one ran.
|
|
2762
|
+
const seq = (state._gitSeq = (state._gitSeq || 0) + 1);
|
|
2763
|
+
gitmod.statuslineInfoAsync(cwd)
|
|
2764
|
+
.then((info) => {
|
|
2765
|
+
if (state._gitSeq !== seq) return; // superseded by a newer refresh
|
|
2766
|
+
state._gitInfo = info;
|
|
2767
|
+
if (typeof state._requestRender === 'function') state._requestRender();
|
|
2768
|
+
})
|
|
2769
|
+
.catch(() => {
|
|
2770
|
+
if (state._gitSeq !== seq) return;
|
|
2771
|
+
state._gitInfo = null; // a git failure must never break rendering
|
|
2772
|
+
if (typeof state._requestRender === 'function') state._requestRender();
|
|
2773
|
+
});
|
|
2615
2774
|
}
|
|
2616
2775
|
|
|
2617
2776
|
// ---- pure frame composer (no TTY side effects) ----
|
|
@@ -3001,8 +3160,15 @@ export function composeFrame(state, cols, rows) {
|
|
|
3001
3160
|
// this too and appended a whole extra viewport of rows ON TOP of the editor.
|
|
3002
3161
|
// The frame then overflowed, the top trim ate the editor's own title and text,
|
|
3003
3162
|
// and /personal & /set-system-prompt showed nothing but a stray caret.
|
|
3004
|
-
|
|
3005
|
-
|
|
3163
|
+
if (!state.editor && !state.panel && !state.form && !state.picker) {
|
|
3164
|
+
// Windowed: renderChatLines computes the visible tail from bodyH+scroll and
|
|
3165
|
+
// fills the rest with padding. `chat` still has the full row count (holes
|
|
3166
|
+
// are blank strings), so `chat.length` remains the true total and the
|
|
3167
|
+
// slices below stay correct. `state._forceFullRender` (tests) bypasses the
|
|
3168
|
+
// window so the full path can be compared.
|
|
3169
|
+
const chat = state._forceFullRender
|
|
3170
|
+
? renderChatLines(state, w)
|
|
3171
|
+
: renderChatLines(state, w, { bodyH, scroll: state.scroll || 0 });
|
|
3006
3172
|
if (state.selection && state.selection.anchor && state.selection.head) {
|
|
3007
3173
|
// Apply selection highlight directly on ANSI strings
|
|
3008
3174
|
for (let i = 0; i < chat.length; i++) {
|
|
@@ -3642,6 +3808,46 @@ function fieldCaretCol(field, active) {
|
|
|
3642
3808
|
const c = Math.max(0, Math.min(shown.length, field.caret || 0));
|
|
3643
3809
|
return visualCol(shown.slice(0, c));
|
|
3644
3810
|
}
|
|
3811
|
+
// ---- prompt history (persisted across restarts) -----------------------------
|
|
3812
|
+
// Kept in the hncode config directory so the history survives a restart; the
|
|
3813
|
+
// in-memory array alone meant every relaunch started with an empty ↑ list.
|
|
3814
|
+
const HISTORY_MAX = 500;
|
|
3815
|
+
|
|
3816
|
+
function historyFile() {
|
|
3817
|
+
try {
|
|
3818
|
+
return path.join(path.dirname(hncodeConfigFile()), 'history.json');
|
|
3819
|
+
} catch {
|
|
3820
|
+
return path.join(os.homedir(), '.hncode', 'history.json');
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
export function loadHistory() {
|
|
3825
|
+
try {
|
|
3826
|
+
const raw = fs.readFileSync(historyFile(), 'utf8');
|
|
3827
|
+
const arr = JSON.parse(raw);
|
|
3828
|
+
if (!Array.isArray(arr)) return [];
|
|
3829
|
+
return arr.filter((x) => typeof x === 'string' && x.trim() !== '').slice(-HISTORY_MAX);
|
|
3830
|
+
} catch {
|
|
3831
|
+
return [];
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
export function saveHistory(list) {
|
|
3836
|
+
try {
|
|
3837
|
+
const file = historyFile();
|
|
3838
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3839
|
+
// Newest last, capped, and de-duplicated while keeping order.
|
|
3840
|
+
const seen = new Set();
|
|
3841
|
+
const out = [];
|
|
3842
|
+
for (const x of (list || []).slice(-HISTORY_MAX)) {
|
|
3843
|
+
if (typeof x !== 'string' || !x.trim()) continue;
|
|
3844
|
+
if (seen.has(x)) continue;
|
|
3845
|
+
seen.add(x);
|
|
3846
|
+
out.push(x);
|
|
3847
|
+
}
|
|
3848
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 0), 'utf8');
|
|
3849
|
+
} catch { /* history is a convenience; never break the session over it */ }
|
|
3850
|
+
}
|
|
3645
3851
|
|
|
3646
3852
|
// ---- state factory ----
|
|
3647
3853
|
export function makeState({ cfg, session, opts }) {
|
|
@@ -3772,7 +3978,7 @@ export function makeState({ cfg, session, opts }) {
|
|
|
3772
3978
|
notice: '',
|
|
3773
3979
|
noticeKind: 'info',
|
|
3774
3980
|
confirmExit: false,
|
|
3775
|
-
history:
|
|
3981
|
+
history: loadHistory(), // restored from disk so ↑ works across restarts
|
|
3776
3982
|
historyIdx: -1,
|
|
3777
3983
|
_tipIndex: 0,
|
|
3778
3984
|
};
|
|
@@ -3803,6 +4009,9 @@ export function reconstructChat(s) {
|
|
|
3803
4009
|
const shellByAnchor = new Map();
|
|
3804
4010
|
const shells = (s && s.shellHistory) || [];
|
|
3805
4011
|
for (const sh of shells) {
|
|
4012
|
+
// A malformed entry must not take the whole resume down: `sh.anchor` on a
|
|
4013
|
+
// null threw right here, before the guard inside emitShells() could help.
|
|
4014
|
+
if (!sh || typeof sh !== 'object') continue;
|
|
3806
4015
|
const a = Number(sh.anchor != null ? sh.anchor : 0);
|
|
3807
4016
|
if (!shellByAnchor.has(a)) shellByAnchor.set(a, []);
|
|
3808
4017
|
shellByAnchor.get(a).push(sh);
|
|
@@ -3810,8 +4019,11 @@ export function reconstructChat(s) {
|
|
|
3810
4019
|
// Emit the shell block for anchor `a` (command + its output) onto `out`.
|
|
3811
4020
|
const emitShells = (out, a) => {
|
|
3812
4021
|
for (const sh of shellByAnchor.get(a) || []) {
|
|
4022
|
+
// Same junk-entry guard as the messages loop: a malformed shellHistory
|
|
4023
|
+
// entry must not take the whole resume down with it.
|
|
4024
|
+
if (!sh || typeof sh !== 'object') continue;
|
|
3813
4025
|
out.push({ role: 'bash', text: String(sh.cmd || '') });
|
|
3814
|
-
if (sh.result) out.push({ role: 'tool_result', text: sh.result, failed: sh.ok === false });
|
|
4026
|
+
if (sh.result) out.push({ role: 'tool_result', text: String(sh.result), failed: sh.ok === false });
|
|
3815
4027
|
}
|
|
3816
4028
|
};
|
|
3817
4029
|
|
|
@@ -3819,6 +4031,11 @@ export function reconstructChat(s) {
|
|
|
3819
4031
|
const msgs = (s && s.messages) || [];
|
|
3820
4032
|
for (let mi = 0; mi < msgs.length; mi++) {
|
|
3821
4033
|
const m = msgs[mi];
|
|
4034
|
+
// Skip junk entries. A session file can carry a null (older writer, hand
|
|
4035
|
+
// edit, truncated write); reading `m.role` off it threw and the whole resume
|
|
4036
|
+
// died with an unhandled TypeError instead of showing the rest of the
|
|
4037
|
+
// conversation.
|
|
4038
|
+
if (!m || typeof m !== 'object') continue;
|
|
3822
4039
|
const role = m.role;
|
|
3823
4040
|
const text = typeof m.content === 'string' ? m.content : '';
|
|
3824
4041
|
// Inject shell commands that anchored BEFORE this message (in the slot after
|
|
@@ -3827,7 +4044,8 @@ export function reconstructChat(s) {
|
|
|
3827
4044
|
if (role === 'user') out.push({ role: 'user', text });
|
|
3828
4045
|
else if (role === 'assistant') {
|
|
3829
4046
|
out.push({ role: 'assistant', text });
|
|
3830
|
-
for (const tc of (m.toolCalls
|
|
4047
|
+
for (const tc of (Array.isArray(m.toolCalls) ? m.toolCalls : [])) {
|
|
4048
|
+
if (!tc || typeof tc !== 'object') continue;
|
|
3831
4049
|
out.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
|
|
3832
4050
|
}
|
|
3833
4051
|
} else if (role === 'tool') {
|
|
@@ -3987,7 +4205,8 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
3987
4205
|
{ label: 'provider', sub: 'manage AI providers' },
|
|
3988
4206
|
{ label: 'statusline', sub: 'configure status line items' },
|
|
3989
4207
|
{ label: 'add-dir', sub: 'add an additional workspace directory' },
|
|
3990
|
-
{ label: 'auto-update', sub:
|
|
4208
|
+
{ label: 'auto-update', sub: `check npm for updates at startup + every 30 min — currently ${cfg.autoUpdate ? 'on' : 'off'}` },
|
|
4209
|
+
{ label: 'auto-compact', sub: `summarize older history at 85% of the window — currently ${cfg.autoCompact === false ? 'off' : 'on'}` },
|
|
3991
4210
|
],
|
|
3992
4211
|
onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
|
|
3993
4212
|
});
|
|
@@ -4795,7 +5014,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
4795
5014
|
'',
|
|
4796
5015
|
'Shortcuts',
|
|
4797
5016
|
' Enter send the message',
|
|
4798
|
-
'
|
|
5017
|
+
' Shift+Enter insert a newline',
|
|
4799
5018
|
' Ctrl+Shift+C copy the selection (or the last answer)',
|
|
4800
5019
|
' Ctrl+Shift+V paste the clipboard (multi-line pastes collapse)',
|
|
4801
5020
|
' ↑ / ↓ input history (empty composer) · scroll the chat',
|
|
@@ -4858,6 +5077,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
4858
5077
|
: 'Auto-update OFF');
|
|
4859
5078
|
return;
|
|
4860
5079
|
}
|
|
5080
|
+
case 'auto-compact': {
|
|
5081
|
+
// Toggle /auto-compact. Persists to config.toml so it survives restarts.
|
|
5082
|
+
const a = raw.toLowerCase();
|
|
5083
|
+
let want;
|
|
5084
|
+
if (a === 'on') want = true;
|
|
5085
|
+
else if (a === 'off') want = false;
|
|
5086
|
+
else want = cfg.autoCompact === false; // flip from the current state
|
|
5087
|
+
try { setConfigString('auto_compact', want ? 'true' : 'false'); }
|
|
5088
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
5089
|
+
cfg.autoCompact = want;
|
|
5090
|
+
cfg.raw.auto_compact = want;
|
|
5091
|
+
app(want
|
|
5092
|
+
? 'Auto-compaction ON — older history is summarized once a request reaches 85% of the context window'
|
|
5093
|
+
: 'Auto-compaction OFF — history is no longer trimmed automatically (use /compact manually)');
|
|
5094
|
+
return;
|
|
5095
|
+
}
|
|
4861
5096
|
case 'update': {
|
|
4862
5097
|
// Manual update check. This is the ONLY place an update is ever reported;
|
|
4863
5098
|
// the automatic background check stays completely silent.
|
|
@@ -4928,28 +5163,35 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
4928
5163
|
case 'statusline':
|
|
4929
5164
|
openPicker({
|
|
4930
5165
|
title: 'Status line items',
|
|
4931
|
-
items:
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
5166
|
+
items: (() => {
|
|
5167
|
+
// `sub` must be set here, not only in onPick: the picker renders
|
|
5168
|
+
// item.sub, so without an initial value every toggle showed a blank
|
|
5169
|
+
// state until the user toggled it once.
|
|
5170
|
+
const toggles = [
|
|
5171
|
+
['permission mode', 'slMode'],
|
|
5172
|
+
['model name', 'slModel'],
|
|
5173
|
+
['thinking effort', 'slEffort'],
|
|
5174
|
+
['current directory', 'slCwd'],
|
|
5175
|
+
['git branch & diff', 'slGit'],
|
|
5176
|
+
['background tasks', 'slTasks'],
|
|
5177
|
+
['rotating tips', 'slTips'],
|
|
5178
|
+
];
|
|
5179
|
+
return toggles.map(([label, key]) => {
|
|
5180
|
+
const isOn = state[key] !== false;
|
|
5181
|
+
return { label, key, kind: 'toggle', isOn, sub: isOn ? 'on' : 'off' };
|
|
5182
|
+
});
|
|
5183
|
+
})(),
|
|
4940
5184
|
hint: '↑↓ navigate · Enter toggle · Esc close',
|
|
4941
5185
|
onPick: (it) => {
|
|
4942
5186
|
it.isOn = !it.isOn;
|
|
4943
5187
|
it.sub = it.isOn ? 'on' : 'off';
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
if (key === 'slGit') refreshGitInfo(state, { force: true });
|
|
5188
|
+
state[it.key] = it.isOn;
|
|
5189
|
+
if (it.key === 'slGit') refreshGitInfo(state, { force: true });
|
|
4947
5190
|
app(`Status line: ${it.label} → ${it.isOn ? 'shown' : 'hidden'}`);
|
|
4948
5191
|
return false;
|
|
4949
5192
|
},
|
|
4950
5193
|
});
|
|
4951
5194
|
return;
|
|
4952
|
-
|
|
4953
5195
|
case 'export-md': {
|
|
4954
5196
|
const msgs = session.messages || [];
|
|
4955
5197
|
if (!msgs.length) { appErr('Nothing to export (empty session).'); return; }
|
|
@@ -5121,7 +5363,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
5121
5363
|
return;
|
|
5122
5364
|
}
|
|
5123
5365
|
case 'plugins': {
|
|
5124
|
-
const { pluginCommands,
|
|
5366
|
+
const { pluginCommands, API } = await import('./plugin.js');
|
|
5367
|
+
// `loadedPlugins` is module-private; the public way to read it is the
|
|
5368
|
+
// API.plugins getter. Importing a non-exported name yielded `undefined`
|
|
5369
|
+
// and crashed on `.length`.
|
|
5370
|
+
const loadedPlugins = API.plugins;
|
|
5125
5371
|
if (!loadedPlugins.length) { app('No plugins loaded. Check ~/.hncode/plugins/'); return; }
|
|
5126
5372
|
const lines = ['Loaded plugins:', ...loadedPlugins.map((p) => ` • ${p.name} v${p.version} (${p.id})`)];
|
|
5127
5373
|
if (pluginCommands.length) {
|
|
@@ -5220,6 +5466,122 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
5220
5466
|
h.openPanel('hncode — git', lines);
|
|
5221
5467
|
return;
|
|
5222
5468
|
}
|
|
5469
|
+
case 'add': {
|
|
5470
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5471
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5472
|
+
// `/add` stages everything (`git add -A`); `/add <path> ...` stages just
|
|
5473
|
+
// those paths. This is the explicit staging step /commit does implicitly.
|
|
5474
|
+
const paths = raw.split(/\s+/).filter(Boolean);
|
|
5475
|
+
const r = gitmod.stage(paths, cwd);
|
|
5476
|
+
if (!r.ok) { appErr(`git add failed: ${r.error}`); return; }
|
|
5477
|
+
const staged = gitmod.stagedFiles(cwd);
|
|
5478
|
+
app(paths.length ? `Staged ${paths.length} path(s).` : 'Staged all changes.');
|
|
5479
|
+
say(`Index now holds ${staged.length} file(s):\n` + staged.map((f) => ' ' + f).join('\n'));
|
|
5480
|
+
return;
|
|
5481
|
+
}
|
|
5482
|
+
case 'diff': {
|
|
5483
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5484
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5485
|
+
const staged = /--staged|--cached/.test(raw);
|
|
5486
|
+
const path = raw.replace(/--staged|--cached/g, '').trim();
|
|
5487
|
+
const r = gitmod.diffText(cwd, { staged, path: path || undefined });
|
|
5488
|
+
if (!r.ok) { appErr(`git diff failed: ${r.error}`); return; }
|
|
5489
|
+
if (r.empty) { say(staged ? 'No staged changes.' : 'No uncommitted changes.'); return; }
|
|
5490
|
+
say((staged ? 'Staged changes' : 'Uncommitted changes') + (path ? ` in ${path}` : '') + ':\n' + r.text);
|
|
5491
|
+
return;
|
|
5492
|
+
}
|
|
5493
|
+
case 'log': {
|
|
5494
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5495
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5496
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
5497
|
+
const count = parts[0] && /^\d+$/.test(parts[0]) ? parts.shift() : undefined;
|
|
5498
|
+
const path = parts.join(' ') || undefined;
|
|
5499
|
+
const r = gitmod.logText(cwd, { count, path });
|
|
5500
|
+
if (!r.ok) { appErr(`git log failed: ${r.error}`); return; }
|
|
5501
|
+
if (r.empty) { say('No commits yet.'); return; }
|
|
5502
|
+
say(`Recent commits${path ? ` for ${path}` : ''}:\n` + r.text);
|
|
5503
|
+
return;
|
|
5504
|
+
}
|
|
5505
|
+
case 'push': {
|
|
5506
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5507
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5508
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
5509
|
+
const force = parts.includes('-f') || parts.includes('--force');
|
|
5510
|
+
const branchArg = parts.find((x) => !x.startsWith('-')) || '';
|
|
5511
|
+
const branch = branchArg || gitmod.currentBranch(cwd);
|
|
5512
|
+
const upstream = gitmod.hasUpstream(cwd, branch);
|
|
5513
|
+
const state2 = gitmod.aheadBehind(cwd, branch);
|
|
5514
|
+
const detail = `${branch}${upstream ? '' : ' (no upstream — will set one)'}${state2 ? ` — ${state2}` : ''}`;
|
|
5515
|
+
// Push is OUTBOUND: always confirm first, and say exactly what will happen.
|
|
5516
|
+
h.openPicker({
|
|
5517
|
+
title: `Push ${branch}?`,
|
|
5518
|
+
items: [
|
|
5519
|
+
{ label: 'Push', sub: `git push ${force ? '--force-with-lease ' : ''}origin ${detail}` },
|
|
5520
|
+
{ label: 'Cancel', sub: 'do not push' },
|
|
5521
|
+
],
|
|
5522
|
+
searchable: false,
|
|
5523
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
5524
|
+
onPick: (it) => {
|
|
5525
|
+
if (it.label !== 'Push') { app('Push cancelled.'); return true; }
|
|
5526
|
+
const r = gitmod.push(cwd, { branch: branchArg || undefined, force, setUpstream: !upstream });
|
|
5527
|
+
if (!r.ok) appErr(`Push failed: ${r.error}`);
|
|
5528
|
+
else { app(`Pushed ${branch}.`); say(`git push output:\n${r.output}`); }
|
|
5529
|
+
return true;
|
|
5530
|
+
},
|
|
5531
|
+
});
|
|
5532
|
+
return;
|
|
5533
|
+
}
|
|
5534
|
+
case 'stash': {
|
|
5535
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5536
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5537
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
5538
|
+
const sub = (parts[0] || '').toLowerCase();
|
|
5539
|
+
if (!sub || sub === 'list') {
|
|
5540
|
+
const list = gitmod.stashList(cwd);
|
|
5541
|
+
say(list.length ? `Stashes (${list.length}):\n` + list.map((s) => ' ' + s).join('\n') : 'No stashes.');
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5544
|
+
if (sub === 'push' || sub === 'save') {
|
|
5545
|
+
const msg = parts.slice(1).join(' ') || undefined;
|
|
5546
|
+
const r = gitmod.stashPush(cwd, msg);
|
|
5547
|
+
if (!r.ok) { appErr(`git stash failed: ${r.error}`); return; }
|
|
5548
|
+
app('Stashed changes.'); say(r.output || 'Changes stashed.');
|
|
5549
|
+
return;
|
|
5550
|
+
}
|
|
5551
|
+
if (sub === 'pop') {
|
|
5552
|
+
const r = gitmod.stashPop(cwd);
|
|
5553
|
+
if (!r.ok) { appErr(`git stash pop failed: ${r.error}`); return; }
|
|
5554
|
+
app('Restored from stash.'); say(r.output || 'Stash popped.');
|
|
5555
|
+
return;
|
|
5556
|
+
}
|
|
5557
|
+
appErr('Usage: /stash [list] | push [message] | pop');
|
|
5558
|
+
return;
|
|
5559
|
+
}
|
|
5560
|
+
case 'rebase': {
|
|
5561
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5562
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5563
|
+
const onto = String(raw || '').trim();
|
|
5564
|
+
if (!onto) { appErr('Usage: /rebase <branch>'); return; }
|
|
5565
|
+
const cur = gitmod.currentBranch(cwd);
|
|
5566
|
+
// Rewrites history on the current branch: confirm before running.
|
|
5567
|
+
h.openPicker({
|
|
5568
|
+
title: `Rebase ${cur} onto ${onto}?`,
|
|
5569
|
+
items: [
|
|
5570
|
+
{ label: 'Rebase', sub: `git rebase ${onto}` },
|
|
5571
|
+
{ label: 'Cancel', sub: 'leave the branch as is' },
|
|
5572
|
+
],
|
|
5573
|
+
searchable: false,
|
|
5574
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
5575
|
+
onPick: (it) => {
|
|
5576
|
+
if (it.label !== 'Rebase') { app('Rebase cancelled.'); return true; }
|
|
5577
|
+
const r = gitmod.rebase(cwd, onto);
|
|
5578
|
+
if (!r.ok) appErr(`Rebase failed (resolve conflicts, then /rebase --continue or git rebase --abort): ${r.error}`);
|
|
5579
|
+
else { app(`Rebased ${cur} onto ${onto}.`); say(r.output || 'Rebase complete.'); }
|
|
5580
|
+
return true;
|
|
5581
|
+
},
|
|
5582
|
+
});
|
|
5583
|
+
return;
|
|
5584
|
+
}
|
|
5223
5585
|
case 'commit': {
|
|
5224
5586
|
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5225
5587
|
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
@@ -5389,13 +5751,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
5389
5751
|
// Check if this is a plugin-registered command.
|
|
5390
5752
|
if (isPluginCommand(cmd)) {
|
|
5391
5753
|
const pc = findCommand(cmd);
|
|
5392
|
-
|
|
5393
|
-
|
|
5754
|
+
// NOTE: `pc` comes from the plugin registry, which does NOT carry the
|
|
5755
|
+
// `_plugin` marker — that is only added by allCommands() for display. The
|
|
5756
|
+
// old guard `pc._plugin` was therefore always falsy, so plugin commands
|
|
5757
|
+
// fell through to "Unknown command" and never ran at all. isPluginCommand
|
|
5758
|
+
// above already established that this name resolves to a plugin.
|
|
5759
|
+
if (pc && typeof pc.run === 'function') {
|
|
5760
|
+
// AWAIT the handler: plugins may register an async `run`, and without
|
|
5761
|
+
// awaiting, a rejection escaped as an unhandled promise rejection and
|
|
5762
|
+
// the catch below never saw it (the command appeared to fail silently).
|
|
5763
|
+
try { await pc.run(raw, { state, cfg, session, h, app, appErr }); }
|
|
5394
5764
|
catch (e) { appErr(`Plugin command "/${cmd}" failed: ${e.message}`); }
|
|
5395
5765
|
return;
|
|
5396
5766
|
}
|
|
5397
5767
|
}
|
|
5398
5768
|
// Unknown command: show error message
|
|
5769
|
+
// Unknown command: show error message
|
|
5399
5770
|
appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
|
|
5400
5771
|
return;
|
|
5401
5772
|
}
|
|
@@ -5477,6 +5848,11 @@ function commandPathsAreInside(state, cmd, cwd) {
|
|
|
5477
5848
|
function applyModel(state, cfg, next) {
|
|
5478
5849
|
cfg.model = next.model; cfg.innerModel = next.innerModel; cfg.provider = next.provider;
|
|
5479
5850
|
cfg.baseUrl = next.baseUrl; cfg.endpoint = next.endpoint; cfg.apiKey = next.apiKey; cfg.protocol = next.protocol;
|
|
5851
|
+
// Carry the resolved context window over too. resolveProvider/resolveModelArg
|
|
5852
|
+
// compute `maxContextTokens` for the NEW model, but it lives on the returned
|
|
5853
|
+
// object — without copying it here, cfg (and the gauge below) kept the previous
|
|
5854
|
+
// model's window after a switch.
|
|
5855
|
+
if (next.maxContextTokens != null) cfg.maxContextTokens = next.maxContextTokens;
|
|
5480
5856
|
state.model = cfg.model; state.provider = cfg.provider;
|
|
5481
5857
|
state.modelLabel = modelLabel(cfg) || cfg.model || '';
|
|
5482
5858
|
state.seenThinking = false;
|
|
@@ -5730,30 +6106,12 @@ export async function startTUI(opts) {
|
|
|
5730
6106
|
const stdout = process.stdout;
|
|
5731
6107
|
const addChat = (msg) => {
|
|
5732
6108
|
state.chat.push(normalizeMsg(msg));
|
|
5733
|
-
//
|
|
5734
|
-
// scroll
|
|
5735
|
-
//
|
|
5736
|
-
//
|
|
5737
|
-
//
|
|
5738
|
-
|
|
5739
|
-
// so appending a tall message (a long Read/Bash result) pushed the whole
|
|
5740
|
-
// transcript up by its height in a single frame instead of scrolling by one
|
|
5741
|
-
// row — the "everything jumps" report. When the user IS scrolled up we leave
|
|
5742
|
-
// the offset alone, which is the other half of the original intent.
|
|
5743
|
-
if ((state.scroll || 0) === 0) {
|
|
5744
|
-
state.scroll = 0; // pinned: keep following the newest message
|
|
5745
|
-
} else {
|
|
5746
|
-
// Scrolled up: grow the offset by exactly the number of rows this message
|
|
5747
|
-
// added, so the content the user is reading does not move.
|
|
5748
|
-
try {
|
|
5749
|
-
const rows = renderChatLines(state, dims().cols).length;
|
|
5750
|
-
if (state._addChatRows != null) {
|
|
5751
|
-
const grew = Math.max(0, rows - state._addChatRows);
|
|
5752
|
-
if (grew) state.scroll = (state.scroll || 0) + grew;
|
|
5753
|
-
}
|
|
5754
|
-
state._addChatRows = rows;
|
|
5755
|
-
} catch { /* rendering is best-effort here; never break a message append */ }
|
|
5756
|
-
}
|
|
6109
|
+
// Auto-follow / re-anchor is delegated to anchorScroll() so exactly ONE place
|
|
6110
|
+
// adjusts `scroll`. This block used to run its own row-delta compensation
|
|
6111
|
+
// against a second baseline (_addChatRows) which — like anchorScroll's old
|
|
6112
|
+
// one — was not refreshed while the view was pinned. After the user scrolled
|
|
6113
|
+
// up, BOTH paths added the same rows, so the view jumped by twice the growth.
|
|
6114
|
+
anchorScroll();
|
|
5757
6115
|
renderFrame();
|
|
5758
6116
|
};
|
|
5759
6117
|
let noticeTimer = null;
|
|
@@ -5784,6 +6142,9 @@ export async function startTUI(opts) {
|
|
|
5784
6142
|
if (out) stdout.write(out);
|
|
5785
6143
|
}
|
|
5786
6144
|
function renderFrame() { paintNow(); }
|
|
6145
|
+
// Let module-level helpers (refreshGitInfo) ask for a repaint once their async
|
|
6146
|
+
// data lands, without holding a reference to this closure.
|
|
6147
|
+
state._requestRender = renderFrame;
|
|
5787
6148
|
// No frame-rate cap: paint on the next event-loop turn. Calls made within the
|
|
5788
6149
|
// same tick still coalesce (paintScheduled), so a burst of stream deltas
|
|
5789
6150
|
// produces one frame per tick at full speed instead of being throttled to
|
|
@@ -6151,11 +6512,46 @@ export async function startTUI(opts) {
|
|
|
6151
6512
|
// and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
|
|
6152
6513
|
warmClipboard();
|
|
6153
6514
|
setTip(state);
|
|
6154
|
-
//
|
|
6155
|
-
//
|
|
6515
|
+
// Fill the git badge — asynchronously, AFTER the first paint. It used to run
|
|
6516
|
+
// before renderFrame() with a blocking spawnSync git, which added ~330ms to
|
|
6517
|
+
// startup; now the frame draws at once and the badge appears when git answers.
|
|
6156
6518
|
refreshGitInfo(state, { force: true });
|
|
6157
6519
|
renderFrame();
|
|
6158
6520
|
|
|
6521
|
+
// MCP: connect in the BACKGROUND, now that the UI is on screen. Each server
|
|
6522
|
+
// gets up to CONNECT_TIMEOUT_MS, so doing this before the first paint would
|
|
6523
|
+
// stall the whole TUI; instead we start it and report the outcome as a notice
|
|
6524
|
+
// ("MCP: connected to …") when it lands. Tools registered here become visible
|
|
6525
|
+
// to the model on its next turn.
|
|
6526
|
+
if (typeof opts.mcpConnect === 'function') {
|
|
6527
|
+
Promise.resolve()
|
|
6528
|
+
.then(() => opts.mcpConnect())
|
|
6529
|
+
.then((res) => {
|
|
6530
|
+
const servers = (res && res.servers) || [];
|
|
6531
|
+
if (!servers.length) return;
|
|
6532
|
+
// The notice line is a SINGLE status-line row, so report all servers in
|
|
6533
|
+
// one message — looping notice() per server only left the last one
|
|
6534
|
+
// visible. Failures are listed too, and the whole notice turns red when
|
|
6535
|
+
// any server failed.
|
|
6536
|
+
const okServers = servers.filter((s) => s.ok);
|
|
6537
|
+
const bad = servers.filter((s) => !s.ok);
|
|
6538
|
+
const parts = [];
|
|
6539
|
+
if (okServers.length) {
|
|
6540
|
+
const tools = okServers.reduce((n, s) => n + (s.toolCount || 0), 0);
|
|
6541
|
+
const names = okServers.map((s) => s.name).join(', ');
|
|
6542
|
+
parts.push(`MCP connected: ${names} (${tools} tool${tools === 1 ? '' : 's'})`);
|
|
6543
|
+
}
|
|
6544
|
+
if (bad.length) {
|
|
6545
|
+
parts.push(`MCP failed: ${bad.map((s) => s.name).join(', ')}`);
|
|
6546
|
+
}
|
|
6547
|
+
notice(parts.join(' · '), bad.length ? 'error' : 'info');
|
|
6548
|
+
// A connecting server may have changed the tool list; repaint so any
|
|
6549
|
+
// tool-count readout is current.
|
|
6550
|
+
renderFrame();
|
|
6551
|
+
})
|
|
6552
|
+
.catch(() => { /* MCP must never break the session */ });
|
|
6553
|
+
}
|
|
6554
|
+
|
|
6159
6555
|
const tipTimer = setInterval(() => { setTip(state); renderFrame(); }, TIP_INTERVAL);
|
|
6160
6556
|
// Git badge: a fixed 15s cadence. Deliberately NOT tied to the frame loop (that
|
|
6161
6557
|
// spawned git per keystroke) and NOT run mid-turn (the agent is still writing;
|
|
@@ -7069,12 +7465,26 @@ export async function startTUI(opts) {
|
|
|
7069
7465
|
renderFrame(); return;
|
|
7070
7466
|
}
|
|
7071
7467
|
if (t.key === 'up' || t.key === 'down') {
|
|
7072
|
-
// ↑/↓
|
|
7073
|
-
//
|
|
7074
|
-
//
|
|
7075
|
-
//
|
|
7076
|
-
|
|
7077
|
-
|
|
7468
|
+
// ↑/↓ while the agent streams: FIRST give the composer its caret move
|
|
7469
|
+
// (a multi-line draft must still navigate), then fall back to scrolling
|
|
7470
|
+
// ONLY when there is nothing else to do. The scroll is what made ↑ feel
|
|
7471
|
+
// stuck while output streamed, so it is now the last resort.
|
|
7472
|
+
const cur = state.input || '';
|
|
7473
|
+
if (cur === '') {
|
|
7474
|
+
// Empty composer: the key belongs to HISTORY, not to the viewport.
|
|
7475
|
+
// With no history the key does nothing at all — silently scrolling
|
|
7476
|
+
// the transcript is what made a bare ↑ feel like it ate the key.
|
|
7477
|
+
if (state.history.length) {
|
|
7478
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7479
|
+
state.historyIdx = t.key === 'up'
|
|
7480
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7481
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7482
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7483
|
+
state.caret = state.input.length;
|
|
7484
|
+
refreshMenu(state); renderFrame();
|
|
7485
|
+
}
|
|
7486
|
+
return;
|
|
7487
|
+
}
|
|
7078
7488
|
const insideW = Math.max(0, dims().cols - 2);
|
|
7079
7489
|
const layout = composerLayout(state, insideW - 3);
|
|
7080
7490
|
const dir = t.key === 'down' ? 1 : -1;
|
|
@@ -7087,6 +7497,8 @@ export async function startTUI(opts) {
|
|
|
7087
7497
|
state.composerSel = null;
|
|
7088
7498
|
renderFrame(); return;
|
|
7089
7499
|
}
|
|
7500
|
+
// Draft is on its last/first row: the key is free. Scroll the transcript
|
|
7501
|
+
// (useful while output streams and you want to read back).
|
|
7090
7502
|
scrollChat(state, t.key === 'up' ? 3 : -3);
|
|
7091
7503
|
renderFrame(); return;
|
|
7092
7504
|
}
|
|
@@ -7095,13 +7507,36 @@ export async function startTUI(opts) {
|
|
|
7095
7507
|
if (t.key === 'up' || t.key === 'down') {
|
|
7096
7508
|
const overlay = state.picker || state.form || state.panel || state.menuOpen
|
|
7097
7509
|
|| state.mentionOpen || state.editor;
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7510
|
+
if (!overlay) {
|
|
7511
|
+
const cur = state.input || '';
|
|
7512
|
+
const insideW = Math.max(0, dims().cols - 2);
|
|
7513
|
+
const layout = composerLayout(state, insideW - 3);
|
|
7514
|
+
const dir = t.key === 'down' ? 1 : -1;
|
|
7515
|
+
const targetRow = layout.caretRow + dir;
|
|
7516
|
+
// The composer is MULTI-LINE: ↑/↓ move the caret between its rows
|
|
7517
|
+
// first, and only when it is already on the first/last row does the
|
|
7518
|
+
// key fall through to history. That is the usual shell/editor rule —
|
|
7519
|
+
// typing a paragraph must not have ↑ yank the draft away.
|
|
7520
|
+
if (cur !== '' && targetRow >= 0 && targetRow < layout.rows.length) {
|
|
7521
|
+
const starts = rowStartOffsets(cur, layout.rows.length, insideW);
|
|
7522
|
+
const colInRow = Math.max(0, layout.caretCol - 1);
|
|
7523
|
+
const targetStart = starts[targetRow] != null ? starts[targetRow] : 0;
|
|
7524
|
+
state.caret = Math.min(cur.length, targetStart + colInRow);
|
|
7525
|
+
state.composerSel = null;
|
|
7526
|
+
renderFrame();
|
|
7527
|
+
return;
|
|
7528
|
+
}
|
|
7529
|
+
// First/last row (or an empty composer): this is history's turn.
|
|
7530
|
+
if (state.history.length) {
|
|
7531
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7532
|
+
state.historyIdx = t.key === 'up'
|
|
7533
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7534
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7535
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7536
|
+
state.caret = state.input.length;
|
|
7537
|
+
refreshMenu(state); renderFrame();
|
|
7538
|
+
}
|
|
7539
|
+
return;
|
|
7105
7540
|
}
|
|
7106
7541
|
}
|
|
7107
7542
|
}
|
|
@@ -7417,15 +7852,20 @@ export async function startTUI(opts) {
|
|
|
7417
7852
|
}
|
|
7418
7853
|
if (t.key === 'up' || t.key === 'down') {
|
|
7419
7854
|
const cur = state.input || '';
|
|
7420
|
-
if (cur === ''
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7855
|
+
if (cur === '') {
|
|
7856
|
+
// Empty composer: history owns the key. With NO history the key does
|
|
7857
|
+
// nothing — it must not silently scroll the transcript.
|
|
7858
|
+
if (state.history.length) {
|
|
7859
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7860
|
+
state.historyIdx = t.key === 'up'
|
|
7861
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7862
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7863
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7864
|
+
state.caret = state.input.length;
|
|
7865
|
+
state.composerSel = null;
|
|
7866
|
+
refreshMenu(state); renderFrame();
|
|
7867
|
+
}
|
|
7868
|
+
return;
|
|
7429
7869
|
}
|
|
7430
7870
|
const insideW = Math.max(0, dims().cols - 2);
|
|
7431
7871
|
const layout = composerLayout(state, insideW - 3);
|
|
@@ -7439,10 +7879,23 @@ export async function startTUI(opts) {
|
|
|
7439
7879
|
state.composerSel = null;
|
|
7440
7880
|
renderFrame(); return;
|
|
7441
7881
|
}
|
|
7442
|
-
|
|
7443
|
-
|
|
7882
|
+
// Caret is already on the first/last row: the composer has no further use
|
|
7883
|
+
// for the key, so it becomes HISTORY. (Scrolling the transcript is still
|
|
7884
|
+
// available on PgUp/PgDn and the wheel — the arrows belong to the input.)
|
|
7885
|
+
if (state.history.length) {
|
|
7886
|
+
if (state.historyIdx === -1) state.historyIdx = state.history.length;
|
|
7887
|
+
state.historyIdx = t.key === 'up'
|
|
7888
|
+
? Math.max(0, state.historyIdx - 1)
|
|
7889
|
+
: Math.min(state.history.length, state.historyIdx + 1);
|
|
7890
|
+
state.input = state.historyIdx >= state.history.length ? '' : (state.history[state.historyIdx] || '');
|
|
7891
|
+
state.caret = state.input.length;
|
|
7892
|
+
state.composerSel = null;
|
|
7893
|
+
refreshMenu(state); renderFrame(); return;
|
|
7894
|
+
}
|
|
7895
|
+
return;
|
|
7444
7896
|
}
|
|
7445
7897
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
7898
|
+
// With the pointer ON the scrollbar, scroll one line per notch: the bar is
|
|
7446
7899
|
// With the pointer ON the scrollbar, scroll one line per notch: the bar is
|
|
7447
7900
|
// the precision control, and 3-line steps there feel jumpy on a long
|
|
7448
7901
|
// transcript. Everywhere else keeps the usual 3-line step.
|
|
@@ -7712,6 +8165,7 @@ export async function startTUI(opts) {
|
|
|
7712
8165
|
state.scroll = Math.min(maxScroll, Math.max(0, (state.scroll || 0) + delta));
|
|
7713
8166
|
}
|
|
7714
8167
|
|
|
8168
|
+
|
|
7715
8169
|
async function submit(forceText) {
|
|
7716
8170
|
// `forceText` means the text came from somewhere other than the composer
|
|
7717
8171
|
// (the queue drain). Clearing the composer in that case would throw away
|
|
@@ -7740,7 +8194,10 @@ export async function startTUI(opts) {
|
|
|
7740
8194
|
state.pulseStart = null;
|
|
7741
8195
|
}
|
|
7742
8196
|
|
|
7743
|
-
if (state.history[state.history.length - 1] !== text)
|
|
8197
|
+
if (state.history[state.history.length - 1] !== text) {
|
|
8198
|
+
state.history.push(text);
|
|
8199
|
+
saveHistory(state.history); // persist so ↑ survives a restart
|
|
8200
|
+
}
|
|
7744
8201
|
// Shell passthrough. The MODE is authoritative (the `!` never entered the
|
|
7745
8202
|
// buffer); a leading `!` in the text is still honoured so a pasted line like
|
|
7746
8203
|
// `!git status` behaves the same as typing it.
|
|
@@ -7868,6 +8325,14 @@ export async function startTUI(opts) {
|
|
|
7868
8325
|
state.rounds = (state.rounds || 0) + 1;
|
|
7869
8326
|
state._stepsBase = state.steps || 0;
|
|
7870
8327
|
state._turnSteps = 0;
|
|
8328
|
+
// Re-base the start animation to THIS instant and paint immediately.
|
|
8329
|
+
// submit() set startAnim before `running` was true, so nothing could render
|
|
8330
|
+
// yet; by the time a frame was actually drawn the 1s animation had already
|
|
8331
|
+
// run out and the type-out was never seen. Stamping it here — the first
|
|
8332
|
+
// moment the row can be painted — makes the animation start on screen.
|
|
8333
|
+
state.startAnim = { start: Date.now() };
|
|
8334
|
+
state.pulseStart = null;
|
|
8335
|
+
renderFrame();
|
|
7871
8336
|
if (session) session.rounds = state.rounds;
|
|
7872
8337
|
// system 固定在开头,且不持久化到 session(否则每轮都会堆一条)。
|
|
7873
8338
|
// 历史里的旧 system 一律跳过,统一使用当前 system prompt:
|
|
@@ -7904,21 +8369,10 @@ export async function startTUI(opts) {
|
|
|
7904
8369
|
// prompt. Injected LAST and deepest-last so a nested file outranks the root one,
|
|
7905
8370
|
// which is the precedence order codex documents. Previously /init wrote this
|
|
7906
8371
|
// file and nothing ever loaded it.
|
|
7907
|
-
|
|
8372
|
+
const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state.workspace || cfg.workspace);
|
|
7908
8373
|
if (agents) {
|
|
7909
8374
|
sysText += '\n\n' + agents;
|
|
7910
8375
|
}
|
|
7911
|
-
// GIT CONTEXT: the current branch and how many files are uncommitted. Cheap
|
|
7912
|
-
// (two git calls) and it stops the model guessing whether the tree is dirty
|
|
7913
|
-
// or which branch it is on. Skipped silently outside a repository.
|
|
7914
|
-
{
|
|
7915
|
-
const gitCwd = state.cwd || state.workspace || cfg.workspace;
|
|
7916
|
-
let gitsum = '';
|
|
7917
|
-
try { gitsum = gitmod.summary(gitCwd); } catch { gitsum = ''; }
|
|
7918
|
-
if (gitsum) {
|
|
7919
|
-
sysText += '\n\nGIT WORKING TREE (this repository, as of now):\n' + gitsum;
|
|
7920
|
-
}
|
|
7921
|
-
}
|
|
7922
8376
|
let messages = [{ role: 'system', content: sysText }];
|
|
7923
8377
|
if (session.messages && session.messages.length) {
|
|
7924
8378
|
for (const m of session.messages) {
|
|
@@ -7935,10 +8389,10 @@ export async function startTUI(opts) {
|
|
|
7935
8389
|
// The approved-plan handoff arrives as a SYSTEM message: it is an instruction
|
|
7936
8390
|
// from the harness, not something the user typed, and it must not show up as a
|
|
7937
8391
|
// user bubble or be persisted as one.
|
|
7938
|
-
|
|
7939
|
-
// 只持久化对话本身,system 不进 session
|
|
8392
|
+
messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
|
|
8393
|
+
// 只持久化对话本身,system 不进 session。转折保存交给 turn 结束处
|
|
8394
|
+
// (agent.run 之后),中途不写盘以保持发消息流畅。
|
|
7940
8395
|
Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
|
|
7941
|
-
sess.saveSession(session);
|
|
7942
8396
|
|
|
7943
8397
|
if (state.plan) {
|
|
7944
8398
|
cfg.toolFilter = ['Read', 'Grep', 'Glob'];
|
|
@@ -8094,16 +8548,19 @@ export async function startTUI(opts) {
|
|
|
8094
8548
|
renderSoon();
|
|
8095
8549
|
return;
|
|
8096
8550
|
}
|
|
8551
|
+
if (e.type === 'compacting') {
|
|
8552
|
+
// In-progress notice: the summary below is a full model round-trip, so
|
|
8553
|
+
// show that work is happening instead of leaving the turn silent.
|
|
8554
|
+
addChat({ role: 'system', text: `Context at ${fmtTokens(e.before || 0)} — auto-compacting…` });
|
|
8555
|
+
renderSoon();
|
|
8556
|
+
return;
|
|
8557
|
+
}
|
|
8097
8558
|
if (e.type === 'compacted') {
|
|
8098
8559
|
state.ctxTokens = e.after || state.ctxTokens;
|
|
8099
8560
|
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
|
|
8100
8561
|
addChat({ role: 'system', text: `Context auto-compacted (${fmtTokens(e.before || 0)} → ${fmtTokens(e.after || 0)} tokens).` });
|
|
8101
8562
|
return;
|
|
8102
8563
|
}
|
|
8103
|
-
if (e.type === 'nudge') {
|
|
8104
|
-
// Silent internal signal - don't show to user
|
|
8105
|
-
return;
|
|
8106
|
-
}
|
|
8107
8564
|
if (e.type === 'incomplete') {
|
|
8108
8565
|
// Orange: this is a warning, not ordinary system output.
|
|
8109
8566
|
addChat({ role: 'warn', text: `Stopped: ${e.reason}. The task may be unfinished — send another message to continue.` });
|
|
@@ -8127,8 +8584,16 @@ export async function startTUI(opts) {
|
|
|
8127
8584
|
if (e.type === 'aborted') {
|
|
8128
8585
|
const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
8129
8586
|
if (t) t.pending = false;
|
|
8587
|
+
// Settle any RUNNING tool rows too. A tool that was mid-flight when the
|
|
8588
|
+
// user hit Esc never gets its tool_result, so its `pending` stayed true
|
|
8589
|
+
// and the `Using <Tool>` name kept pulsing forever. Clearing it drops the
|
|
8590
|
+
// name to plain cyan immediately.
|
|
8591
|
+
for (const m of state.chat) {
|
|
8592
|
+
if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; }
|
|
8593
|
+
}
|
|
8130
8594
|
// Use a special role that won't show the ✓ icon
|
|
8131
8595
|
state.chat.push({ role: 'aborted', text: C.red + 'interrupted' + C.reset });
|
|
8596
|
+
renderFrame();
|
|
8132
8597
|
return;
|
|
8133
8598
|
}
|
|
8134
8599
|
if (e.type === 'steer') {
|
|
@@ -8299,12 +8764,12 @@ export async function startTUI(opts) {
|
|
|
8299
8764
|
if (agent.ctx) {
|
|
8300
8765
|
agent.ctx._onBackgroundTaskDone = (task) => {
|
|
8301
8766
|
if (!task) return;
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8767
|
+
// kimi-style lifecycle card: bullet colour by phase, a headline naming
|
|
8768
|
+
// the kind (agent / bash / question task) and what happened, and a dim
|
|
8769
|
+
// detail line. The task's conclusion is kept as the expandable body.
|
|
8770
|
+
const card = backgroundTaskCard(task);
|
|
8306
8771
|
const body = String(task.output || '').trim();
|
|
8307
|
-
addChat({ role: '
|
|
8772
|
+
addChat({ role: 'bg_task', card, text: body });
|
|
8308
8773
|
};
|
|
8309
8774
|
}
|
|
8310
8775
|
await agent.run();
|
|
@@ -8494,19 +8959,23 @@ export async function startTUI(opts) {
|
|
|
8494
8959
|
// Appending streamed text can also add rows (the message wraps as it grows).
|
|
8495
8960
|
// While the user is scrolled up, compensate so their view does not shift — the
|
|
8496
8961
|
// same anchoring rule addChat applies for a whole new message.
|
|
8497
|
-
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
//
|
|
8502
|
-
//
|
|
8503
|
-
//
|
|
8504
|
-
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8962
|
+
function anchorScroll() {
|
|
8963
|
+
// The arithmetic lives in anchorScrollNext() (module level, unit-tested): it
|
|
8964
|
+
// takes the current chat row count and returns the new `scroll`.
|
|
8965
|
+
// * the baseline is refreshed even while the view is pinned at the bottom,
|
|
8966
|
+
// so the first append after the user scrolls up has a real delta;
|
|
8967
|
+
// * the compensation is the rows added since the previous paint.
|
|
8968
|
+
//
|
|
8969
|
+
// The count comes from state._chatTotal, which composeFrame() refreshes on
|
|
8970
|
+
// every paint. Calling renderChatLines() here (as this used to) laid out the
|
|
8971
|
+
// WHOLE transcript a second time on every streamed chunk — a full ~25k-row
|
|
8972
|
+
// render per chunk on top of the windowed one, which is what made markdown
|
|
8973
|
+
// output appear to stall.
|
|
8974
|
+
const rows = state._chatTotal || 0;
|
|
8975
|
+
state.scroll = anchorScrollNext(state, rows);
|
|
8508
8976
|
}
|
|
8509
8977
|
|
|
8978
|
+
|
|
8510
8979
|
// Live split of the streamed reply into prose vs. <plan> block.
|
|
8511
8980
|
// While Plan mode is on and the model is inside <plan>…</plan>, the text is
|
|
8512
8981
|
// routed into a `plan` bubble so it renders in its frame AS IT ARRIVES — the old
|
|
@@ -8630,6 +9099,10 @@ export async function startTUI(opts) {
|
|
|
8630
9099
|
failed: !!(m && m.failed),
|
|
8631
9100
|
};
|
|
8632
9101
|
if (m && Array.isArray(m.diff)) out.diff = m.diff;
|
|
9102
|
+
// Keep `card`: a background-task lifecycle card carries its phase/headline/
|
|
9103
|
+
// detail here (see the `bg_task` renderer). Dropping it left the row with the
|
|
9104
|
+
// fallback text instead of what actually happened.
|
|
9105
|
+
if (m && m.card) out.card = m.card;
|
|
8633
9106
|
return out;
|
|
8634
9107
|
}
|
|
8635
9108
|
|