@hncode/hncode 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.js CHANGED
@@ -28,13 +28,20 @@ import * as sess from './session.js';
28
28
  import { resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, effortOptions, effortWire, rememberModel, hncodeConfigFile, resolveConfig, setConfigString, readPersonalPrompt, readPersonalPromptRaw, writePersonalPrompt, personalPromptFile, readAgentsMd, agentsFilesFor } from './config.js';
29
29
  import { pluginCommands } from './plugin.js';
30
30
  import { OTHER_LABEL, SUPPLEMENT_LABEL } from './tools/ask-user-question.js';
31
+ import { getTool } from './tools/index.js';
31
32
  import { FAMILIES, TASKS, buildPreset, presetLabel } from './prompt-presets.js';
32
33
  import * as upd from './update.js';
33
34
  import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
34
35
  import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
35
36
  import { renderSwarmProgress } from './swarm-progress.js';
36
- import { sortedTasks, getTask, settleTask, STATUS_LABEL } from './agent-task.js';
37
+ import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
37
38
  import { localVersion } from './version.js';
39
+ import { listSkills, readSkill, importSkill, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
40
+ import { loadHooks, describeHooks, runShellHooks, HOOK_EVENTS } from './hooks.js';
41
+ import * as gitmod from './git.js';
42
+ import { reviewLines, savePlan, filesReferenced, planStats, listPlans, plansDir } from './plan.js';
43
+ import { loadMcpConfig, describeServers, getLiveConnections } from './mcp.js';
44
+ import { startControlServer } from './ci.js';
38
45
 
39
46
  const ESC = '\x1b';
40
47
  const VERSION = localVersion();
@@ -82,10 +89,26 @@ export const COMMANDS = [
82
89
  { name: 'move', desc: 'Move current session to another directory (must exist)', priority: 60, argumentHint: '<path>' },
83
90
  { name: 'reload', desc: 'Reload config.toml settings', priority: 60 },
84
91
  { name: 'plugins', desc: 'List loaded plugins and their status', priority: 60 },
92
+ { name: 'skills', desc: 'List installed skills, or remove one', priority: 60, argumentHint: '[remove <name>]' },
93
+ { name: 'import-skill', desc: 'Import a Markdown file as a skill (a duplicate name is overwritten)', priority: 60, argumentHint: '<file.md> [name]' },
94
+ { name: 'hooks', desc: 'Show configured shell hooks, or test one', priority: 60, argumentHint: '[list | test <event>]' },
95
+ { name: 'git', desc: 'Show repository status (branch, changes, remote)', priority: 62 },
96
+ { name: 'commit', desc: 'Stage and commit with a message (or let the agent draft one)', priority: 62, argumentHint: '[message]' },
97
+ { name: 'branch', desc: 'List, create, or switch git branches', priority: 62, argumentHint: '[list | <name> | -c <name>]' },
98
+ { name: 'worktree', desc: 'List, add, or remove git worktrees for parallel work', priority: 60, argumentHint: '[list | add <dir> [branch] | remove <dir>]' },
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>' },
106
+ { name: 'cache', desc: 'Toggle prompt caching for long stable prefixes (saves input tokens)', priority: 58, argumentHint: '[on|off]' },
85
107
  { name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
86
108
  { name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
87
109
  { name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
88
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]' },
89
112
  { name: 'version', desc: 'Show version information', priority: 20 },
90
113
  { name: 'exit', aliases: ['quit', 'q'], desc: 'Exit the application', priority: 20 },
91
114
  ].sort((a, b) => (b.priority || 0) - (a.priority || 0) || a.name.localeCompare(b.name));
@@ -191,6 +214,7 @@ export function extractPlan(text) {
191
214
 
192
215
  export const TIPS = [
193
216
  'Press Esc to interrupt the current turn at any time',
217
+ '!git status runs a shell command directly — no model round-trip',
194
218
  '/init generates an AGENTS.md from your codebase for smarter agent context',
195
219
  'Use /mcp to manage MCP servers for additional tool integration',
196
220
  'The context line shows real-time token usage — watch it grow as the agent works',
@@ -820,8 +844,17 @@ function wrapWithOffsets(text, width) {
820
844
  // not use it — they render their own inputs (search box, form fields).
821
845
  // The prompt glyph matches the transcript's user marker (`❯`) so the composer
822
846
  // reads as "the same kind of thing" as a sent user message.
847
+ //
848
+ // SHELL MODE (`!`) — modelled on kimi-code's editor. Typing `!` on an EMPTY
849
+ // composer flips a mode flag instead of inserting the character; the `!` is
850
+ // rendered as the PREFIX. That is what keeps it from appearing twice:
851
+ // - the buffer holds only the command (`git status`), never the bang
852
+ // - the caret never has to step over a marker it is not editing
853
+ // - submit never has to strip anything
854
+ // The mode ends on Esc/Backspace while the buffer is empty, or on submit.
823
855
  export function composerInput(state) {
824
- return { text: state.input || '', caret: state.caret || 0, prefix: '❯ ' };
856
+ const shell = state.inputMode === 'bash';
857
+ return { text: state.input || '', caret: state.caret || 0, prefix: shell ? '! ' : '❯ ' };
825
858
  }
826
859
 
827
860
  // Marker text for a collapsed multi-line paste, e.g. `[paste #1 +12 lines]`.
@@ -969,7 +1002,7 @@ export function caretBlock(row, col) {
969
1002
  // prefix; wrapped continuation rows are indented to match. Explicit newlines
970
1003
  // start a fresh row. Returns the rows plus the caret's (row, col) within them
971
1004
  // (col already includes the leading border column).
972
- function composerLayout(state, insideW) {
1005
+ export function composerLayout(state, insideW) {
973
1006
  const { text: rawText, caret: rawCaret, prefix } = composerInput(state);
974
1007
  const text = rawText;
975
1008
  const caret = Math.max(0, Math.min(text.length, rawCaret));
@@ -1072,6 +1105,7 @@ function msgColorFor(role, text) {
1072
1105
  if (role === 'system' && typeof text === 'string' && text.startsWith('[turn took')) return C.gray;
1073
1106
  if (role === 'system') return C.teal;
1074
1107
  if (role === 'user') return C.cyan;
1108
+ if (role === 'bash') return C.shellMode; // ! shell-mode echo: violet, like kimi
1075
1109
  if (role === 'warn') return C.orange; // unfinished-turn warning
1076
1110
  if (role === 'queued') return C.gray; // pending, not yet sent
1077
1111
  if (role === 'steer') return C.yellow; // injected into the running turn
@@ -1470,13 +1504,23 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1470
1504
 
1471
1505
  // Write: stream the file content while the model is still emitting it.
1472
1506
  if (name === 'Write' && typeof msg.streamContent === 'string' && msg.streamContent.length) {
1473
- const lines = sanitizeText(msg.streamContent).replace(/\r\n/g, '\n').split('\n');
1507
+ // Only the first MAX lines are drawn, so sanitize+split a bounded PREFIX.
1508
+ // Doing it over the whole accumulated content re-processed every byte on
1509
+ // each frame — O(n²) over the stream, which blocked the main thread on a
1510
+ // large file. The cut is generous (64 KB) to cover MAX long lines; the
1511
+ // line count for the "… N more lines" note is taken from the message,
1512
+ // which tracks the true size cheaply.
1513
+ const SCAN_BYTES = 64 * 1024;
1514
+ const head = msg.streamContent.length > SCAN_BYTES ? msg.streamContent.slice(0, SCAN_BYTES) : msg.streamContent;
1515
+ const lines = sanitizeText(head).replace(/\r\n/g, '\n').split('\n');
1474
1516
  const MAX = 20;
1475
- const wNum = String(lines.length).length;
1517
+ const totalLines = msg._streamLineCount || lines.length;
1518
+ const wNum = String(totalLines).length;
1476
1519
  lines.slice(0, MAX).forEach((ln, i) => {
1477
1520
  out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: i === 0 ? '↳ ' : indent, raw: true });
1478
1521
  });
1479
- if (lines.length > MAX) out.push({ text: col(`… ${lines.length - MAX} more lines`, C.gray), ind: indent, raw: true });
1522
+ const shown = Math.min(lines.length, MAX);
1523
+ if (totalLines > shown) out.push({ text: col(`… ${totalLines - shown} more lines`, C.gray), ind: indent, raw: true });
1480
1524
  }
1481
1525
 
1482
1526
  // The Edit diff is NOT drawn here: it rides on the tool_result message and is
@@ -1524,6 +1568,37 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1524
1568
  return out;
1525
1569
  }
1526
1570
 
1571
+ // A BACKGROUND TASK lifecycle card (kimi-style):
1572
+ // ● agent task completed in background (review the diff)
1573
+ // ✗ bash task failed in background (npm test · exit 1)
1574
+ // The bullet is `●` normally and `✗` for a failure; the colour follows the
1575
+ // phase. The task's conclusion sits underneath, indented, and is capped like
1576
+ // a tool result so one chatty subagent cannot flood the transcript.
1577
+ if (msg.role === 'bg_task') {
1578
+ const card = msg.card || { phase: 'completed', headline: 'background task finished', detail: undefined };
1579
+ const tone = card.phase === 'started' ? C.cyan : card.phase === 'completed' ? C.green : C.red;
1580
+ const bullet = card.phase === 'failed' ? '✗ ' : '● ';
1581
+ const out = [];
1582
+ const detail = card.detail ? col(` (${card.detail})`, C.gray) : '';
1583
+ out.push({ text: col(bullet, tone) + col(card.headline, tone) + detail, ind: '', raw: true });
1584
+ const body = String(msg.text || '').trim();
1585
+ if (body) {
1586
+ const indent = ' ';
1587
+ const avail = Math.max(1, width - visualCol(indent));
1588
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
1589
+ const MAX = expanded ? 200 : 6;
1590
+ for (const ln of lines.slice(0, MAX)) {
1591
+ for (const w of (ln === '' ? [''] : wrapWords(ln, avail))) {
1592
+ out.push({ text: col(w, C.gray), ind: indent, raw: true });
1593
+ }
1594
+ }
1595
+ if (lines.length > MAX) {
1596
+ out.push({ text: col(`… ${lines.length - MAX} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
1597
+ }
1598
+ }
1599
+ return out;
1600
+ }
1601
+
1527
1602
  // Tool output: a single "↳ " marker on the first line, then the output
1528
1603
  // indented by 2 columns. Long output is capped (kimi caps result previews
1529
1604
  // too) so one command cannot flood the whole transcript.
@@ -1668,6 +1743,7 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1668
1743
  switch (msg.role) {
1669
1744
  case 'system': icon = ''; break;
1670
1745
  case 'user': icon = '❯'; break;
1746
+ case 'bash': icon = '!'; break; // shell-mode echo
1671
1747
  case 'warn': icon = '⚑'; break; // ⚑ unfinished-turn warning
1672
1748
  // `queued` keeps the SAME marker as a user message: it is a user message, just
1673
1749
  // not sent yet. Its pending state is shown by the colour (gray) and the queue
@@ -1685,8 +1761,8 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1685
1761
  // The marker keeps the theme colour (cyan) while a USER message renders its
1686
1762
  // text in white — the whole line used to inherit the role colour, so user
1687
1763
  // text came out cyan too. `indColor` colours the marker only.
1688
- const indColor = msg.role === 'user' ? C.cyan : fg;
1689
- const bodyFg = msg.role === 'user' ? C.white : fg;
1764
+ const indColor = msg.role === 'user' ? C.cyan : msg.role === 'bash' ? C.shellMode : fg;
1765
+ const bodyFg = msg.role === 'user' ? C.white : msg.role === 'bash' ? C.shellMode : fg;
1690
1766
  const codeFg = C.cyan;
1691
1767
  const lines = [];
1692
1768
  // Only assistant/thinking output benefits from Markdown; user/system/tool
@@ -1959,6 +2035,10 @@ function rowToLine(r) {
1959
2035
  // Pull the string values that are ALREADY complete out of a partially streamed
1960
2036
  // JSON argument blob, so a tool row can render its key argument while the model
1961
2037
  // is still emitting it. Only complete `"key":"value"` pairs are returned.
2038
+ //
2039
+ // NOTE: this rescans the WHOLE blob and is O(n) per call. Callers that see one
2040
+ // call per streamed chunk must use createArgStream()/feedArgStream() instead —
2041
+ // see the comment there for why re-scanning freezes the UI.
1962
2042
  function extractPartialArgs(raw, prev) {
1963
2043
  const out = { ...(prev || {}) };
1964
2044
  const s = String(raw || '');
@@ -1974,6 +2054,186 @@ function extractPartialArgs(raw, prev) {
1974
2054
  return out;
1975
2055
  }
1976
2056
 
2057
+ // Incremental scanner for a streamed JSON arguments blob.
2058
+ //
2059
+ // WHY THIS EXISTS: a tool call's arguments arrive in many small chunks, and the
2060
+ // TUI used to re-parse the ENTIRE accumulated blob on every chunk — one full
2061
+ // regex pass (extractPartialArgs) plus one full character-by-character decode
2062
+ // (extractJsonString) per chunk. That is O(n²) in the argument size, all of it
2063
+ // synchronous on the main thread between network reads, so the UI could not
2064
+ // repaint. Measured: streaming a 500 KB Write cost ~5.2 s of blocked main
2065
+ // thread, and a larger one hung the process.
2066
+ //
2067
+ // This scanner walks the blob ONCE: every character is consumed exactly once,
2068
+ // across all chunks, and the state carries forward. Closed pairs land in
2069
+ // `pairs`; the value still being read is available via argStreamValue() so a
2070
+ // Write can show its content live. Only flat string args are tracked, which is
2071
+ // all the tool schemas use and all the display reads.
2072
+ export function createArgStream() {
2073
+ return {
2074
+ pairs: {}, // complete "key":"value" pairs
2075
+ mode: 'seekKey', // seekKey | inKey | seekColon | seekVal | inVal
2076
+ key: '',
2077
+ cur: '', // key whose value is currently being read
2078
+ val: '',
2079
+ esc: false, // previous char was a backslash inside a string
2080
+ uni: null, // non-null while collecting the 4 hex digits of \uXXXX
2081
+ // Newlines in the value currently being read (or, once closed, in the pair
2082
+ // stored under `lastKey`). Maintained as the value is decoded so the live
2083
+ // Write preview never has to re-scan the whole content to count lines.
2084
+ valLines: 1,
2085
+ lastKey: '',
2086
+ lastKeyLines: 1,
2087
+ };
2088
+ }
2089
+
2090
+ const ARG_ESCAPES = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
2091
+
2092
+ // Feed one chunk into the stream. Returns the stream for chaining.
2093
+ export function feedArgStream(st, chunk) {
2094
+ const s = String(chunk == null ? '' : chunk);
2095
+ if (!st || !s) return st;
2096
+
2097
+ for (let i = 0; i < s.length; i++) {
2098
+ const c = s[i];
2099
+ if (st.mode === 'seekKey') {
2100
+ if (c === '"') { st.mode = 'inKey'; st.key = ''; }
2101
+ continue;
2102
+ }
2103
+ if (st.mode === 'inKey') {
2104
+ if (st.esc) { st.key += c; st.esc = false; continue; }
2105
+ if (c === '\\') { st.esc = true; continue; }
2106
+ if (c === '"') { st.mode = 'seekColon'; continue; }
2107
+ st.key += c;
2108
+ continue;
2109
+ }
2110
+ if (st.mode === 'seekColon') {
2111
+ if (c === ':') { st.mode = 'seekVal'; continue; }
2112
+ if (/\s/.test(c)) continue;
2113
+ // A quoted string that is NOT followed by `:` is not a key — it is an array
2114
+ // element such as `["x"]`. Abandon it and re-scan this char in seekKey so
2115
+ // the scan stays in sync (the original flat regex never matched those, and
2116
+ // treating one as a key desynced everything after it).
2117
+ st.mode = 'seekKey';
2118
+ i--;
2119
+ continue;
2120
+ }
2121
+ if (st.mode === 'seekVal') {
2122
+ if (/\s/.test(c)) continue;
2123
+ if (c === '"') { st.mode = 'inVal'; st.val = ''; st.cur = st.key; st.valLines = 1; continue; }
2124
+ // A non-string value (number/bool/null/object/array). We do not track those,
2125
+ // so resume scanning for the next key. Every char is still consumed once.
2126
+ st.mode = 'seekKey';
2127
+ continue;
2128
+ }
2129
+ // mode === 'inVal'
2130
+ if (st.uni !== null) {
2131
+ st.uni += c;
2132
+ if (st.uni.length === 4) {
2133
+ const cp = parseInt(st.uni, 16) || 0;
2134
+ st.val += String.fromCharCode(cp);
2135
+ if (cp === 10) st.valLines++;
2136
+ st.uni = null;
2137
+ }
2138
+ continue;
2139
+ }
2140
+ if (st.esc) {
2141
+ st.esc = false;
2142
+ if (c === 'u') { st.uni = ''; continue; }
2143
+ const dec = ARG_ESCAPES[c] !== undefined ? ARG_ESCAPES[c] : c;
2144
+ st.val += dec;
2145
+ if (dec === '\n') st.valLines++;
2146
+ continue;
2147
+ }
2148
+ if (c === '\\') { st.esc = true; continue; }
2149
+ if (c === '"') {
2150
+ st.pairs[st.cur] = st.val;
2151
+ st.lastKey = st.cur;
2152
+ st.lastKeyLines = st.valLines;
2153
+ st.cur = ''; st.val = '';
2154
+ st.mode = 'seekKey';
2155
+ continue;
2156
+ }
2157
+ // Fast path: copy the whole run of ordinary characters (no quote, backslash
2158
+ // or newline) in ONE slice instead of char by char. Most of a streamed value
2159
+ // is plain text, and this is what keeps a multi-MB Write from pinning the
2160
+ // main thread in the per-character loop.
2161
+ {
2162
+ let j = i;
2163
+ while (j < s.length) {
2164
+ const cj = s.charCodeAt(j);
2165
+ if (cj === 34 /* " */ || cj === 92 /* \ */ || cj === 10 /* \n */) break;
2166
+ j++;
2167
+ }
2168
+ if (j > i) { st.val += s.slice(i, j); i = j - 1; continue; }
2169
+ }
2170
+ st.val += c;
2171
+ if (c === '\n') st.valLines++;
2172
+ }
2173
+ return st;
2174
+ }
2175
+
2176
+ // The value of `key` — the completed one when the pair has closed, otherwise the
2177
+ // partial text decoded so far (which is what a live Write preview needs).
2178
+ export function argStreamValue(st, key) {
2179
+ if (!st) return '';
2180
+ if (Object.prototype.hasOwnProperty.call(st.pairs, key)) return st.pairs[key];
2181
+ if (st.mode === 'inVal' && st.cur === key) return st.val;
2182
+ return '';
2183
+ }
2184
+
2185
+ // Line count of `key`'s value, tracked incrementally (no re-scan). Used by the
2186
+ // streaming Write preview for its "… N more lines" note.
2187
+ export function argStreamLineCount(st, key) {
2188
+ if (!st) return 1;
2189
+ if (st.mode === 'inVal' && st.cur === key) return st.valLines;
2190
+ if (st.lastKey === key) return st.lastKeyLines;
2191
+ const v = argStreamValue(st, key);
2192
+ if (!v) return 1;
2193
+ let n = 1;
2194
+ for (let i = 0; i < v.length; i++) if (v.charCodeAt(i) === 10) n++;
2195
+ return n;
2196
+ }
2197
+
2198
+ // Cheap fingerprint of a string for render-cache keys.
2199
+ //
2200
+ // It must NOT index or slice a LARGE string: `argStreamValue()` returns a cons
2201
+ // string built by repeated `+=`, and any read beyond `.length` forces V8 to
2202
+ // FLATTEN the whole rope — O(n). Doing that once per streamed chunk is O(n²) and
2203
+ // measured at ~4.5 s for a 10 MB Write, which is exactly the main-thread block
2204
+ // this fix exists to remove.
2205
+ //
2206
+ // So: small strings are embedded verbatim (accurate); large ones are represented
2207
+ // by their LENGTH only, which is O(1) even on a cons string. That is sufficient
2208
+ // here because every string keyed this way is append-only while it is being
2209
+ // streamed (assistant text and a Write's content only ever grow), and is written
2210
+ // once when a turn completes. A large equal-length replacement does not occur on
2211
+ // these fields — the diff/content that can change arbitrarily live elsewhere.
2212
+ const FP_VERBATIM_MAX = 4096;
2213
+ function fp(s) {
2214
+ if (s == null) return '0';
2215
+ const str = String(s);
2216
+ const n = str.length;
2217
+ return n <= FP_VERBATIM_MAX ? n + ':' + str : n + ':';
2218
+ }
2219
+
2220
+ // Fingerprint of a tool-args object for the render-cache key. `JSON.stringify`
2221
+ // was used here and stringified EVERY argument — including a streaming Write's
2222
+ // whole file content — on every frame. The rendered tool line only shows the key
2223
+ // argument (see keyArgument), so hashing each value's length plus a bounded
2224
+ // sample is enough to detect the change, and is O(#keys) instead of O(bytes).
2225
+ function fpArgs(args) {
2226
+ if (!args || typeof args !== 'object') return '0';
2227
+ const keys = Object.keys(args);
2228
+ if (!keys.length) return '0';
2229
+ let out = '';
2230
+ for (const k of keys) {
2231
+ const v = args[k];
2232
+ out += k + '=' + (typeof v === 'string' ? fp(v) : (v === undefined ? 'u' : String(v).slice(0, 32))) + ';';
2233
+ }
2234
+ return out;
2235
+ }
2236
+
1977
2237
  function renderChatLines(state, w) {
1978
2238
  const out = [];
1979
2239
  const expanded = !!state.expanded;
@@ -1988,7 +2248,9 @@ function renderChatLines(state, w) {
1988
2248
  // like the input box it was typed into.
1989
2249
  // `steer` gets NO box: it is injected into the turn already in progress, so a
1990
2250
  // box would falsely suggest a separate turn had started.
1991
- const bordered = msg.role === 'user' || msg.role === 'queued';
2251
+ const bordered = msg.role === 'user' || msg.role === 'queued' || msg.role === 'bash';
2252
+ // A bash-mode echo uses the violet shellMode frame — same hue as kimi.
2253
+ const boxColor = msg.role === 'bash' ? C.shellMode : C.border;
1992
2254
  // The box spans one column less than the full inner width, so the right edge
1993
2255
  // has a 1-column margin (matching the left margin from `PAD`).
1994
2256
  const boxW = innerW - 2; // total box width, including both │
@@ -1996,8 +2258,8 @@ function renderChatLines(state, w) {
1996
2258
  // One column of padding inside each wall, mirroring the composer box.
1997
2259
  const boxPad = Math.min(1, Math.max(0, boxInner - 1));
1998
2260
  const boxText = Math.max(1, boxInner - boxPad * 2);
1999
- const topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', C.border);
2000
- const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', C.border);
2261
+ const topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
2262
+ const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
2001
2263
  // A box always needs its TOP border — including when the message is the first
2002
2264
  // row of the transcript. (The earlier `out.length > 0` guard came from the
2003
2265
  // rule-only version, where a leading divider looked wrong; a box without a
@@ -2019,8 +2281,8 @@ function renderChatLines(state, w) {
2019
2281
  // its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
2020
2282
  // the 80ms tick no longer re-wraps the reasoning preview every frame.
2021
2283
  const spinKey = (msg.role === 'tool' && msg.pending) ? (state.spin || 0) : 0;
2022
- const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
2023
- + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`
2284
+ const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${fp(msg.text)}\u0000${fp(msg.streamContent)}`
2285
+ + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${fpArgs(msg.toolArgs)}`
2024
2286
  + `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
2025
2287
  let cached = msg._cache;
2026
2288
  if (!cached || cached.key !== key) {
@@ -2038,7 +2300,7 @@ function renderChatLines(state, w) {
2038
2300
  if (bordered) {
2039
2301
  const pad = ' '.repeat(boxPad);
2040
2302
  for (const r of cached.rows) {
2041
- out.push(PAD + col('│', C.border) + pad + fitAnsi(live(r), boxText) + pad + col('│', C.border));
2303
+ out.push(PAD + col('│', boxColor) + pad + fitAnsi(live(r), boxText) + pad + col('│', boxColor));
2042
2304
  }
2043
2305
  } else {
2044
2306
  for (const r of cached.rows) out.push(PAD + live(r));
@@ -2361,6 +2623,46 @@ function sliceAnsi(s, width) {
2361
2623
  return out + (out.includes('\x1b') ? C.reset : '');
2362
2624
  }
2363
2625
 
2626
+ // ---- git status for the status line ----------------------------------------
2627
+ // The badge shows the branch and the uncommitted diff totals. Computing that
2628
+ // costs a handful of git subprocesses, so it is NEVER done from the render path:
2629
+ // composeFrame only reads `state._gitInfo`. It is refreshed from exactly two
2630
+ // places —
2631
+ // 1. a 15s timer while the TUI is idle (see GIT_REFRESH_MS), and
2632
+ // 2. the end of every turn (the agent's edits land there), plus explicit
2633
+ // commands like /git and /move.
2634
+ // Frame-rate polling was the previous design and cost a git spawn per keystroke;
2635
+ // a fixed cadence is both cheaper and more predictable.
2636
+ const GIT_REFRESH_MS = 15000;
2637
+ export function refreshGitInfo(state, opts = {}) {
2638
+ const cwd = state.cwd || state.workspace || '';
2639
+ if (!cwd) return;
2640
+ const now = Date.now();
2641
+ const force = opts.force === true;
2642
+ // Mid-turn the agent is about to write more files, so a poll would only produce
2643
+ // a value that is stale on arrival. The turn-end refresh covers it. Explicit
2644
+ // callers (the timer between turns, /git) still get through with force.
2645
+ if (!force && state.running) return;
2646
+ state._gitCwd = cwd;
2647
+ state._gitAt = now;
2648
+ // ASYNC: does not block the caller. On the startup path this is what lets the
2649
+ // first frame draw immediately — the badge appears a moment later when git
2650
+ // answers, instead of freezing the TUI behind four spawnSync calls. `seq`
2651
+ // discards a stale reply if another refresh started while this one ran.
2652
+ const seq = (state._gitSeq = (state._gitSeq || 0) + 1);
2653
+ gitmod.statuslineInfoAsync(cwd)
2654
+ .then((info) => {
2655
+ if (state._gitSeq !== seq) return; // superseded by a newer refresh
2656
+ state._gitInfo = info;
2657
+ if (typeof state._requestRender === 'function') state._requestRender();
2658
+ })
2659
+ .catch(() => {
2660
+ if (state._gitSeq !== seq) return;
2661
+ state._gitInfo = null; // a git failure must never break rendering
2662
+ if (typeof state._requestRender === 'function') state._requestRender();
2663
+ });
2664
+ }
2665
+
2364
2666
  // ---- pure frame composer (no TTY side effects) ----
2365
2667
  // kimi-code-cli layout: NO top chrome. Top-to-bottom it is:
2366
2668
  // chat (fills the top) / composer box / [command menu] / status line /
@@ -2369,6 +2671,8 @@ function sliceAnsi(s, width) {
2369
2671
  export function composeFrame(state, cols, rows) {
2370
2672
  const w = Math.max(20, cols | 0);
2371
2673
  const h = Math.max(12, rows | 0);
2674
+ // NOTE: no git call here. The render path only READS state._gitInfo; refreshing
2675
+ // it is driven by a 15s timer and by turn-end (see refreshGitInfo).
2372
2676
  // Full-screen takeovers (kimi's screen-takeover): the /tasks browser and its
2373
2677
  // output viewer own the ENTIRE screen, so they short-circuit the normal
2374
2678
  // layout ? no chat, no composer, no status bar.
@@ -3033,19 +3337,21 @@ export function composeFrame(state, cols, rows) {
3033
3337
  lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
3034
3338
  }
3035
3339
 
3036
- // Plan approval: shown once the model hands over a <plan> in Plan mode. Same
3037
- // box shape as the tool approval above so the two read as the same kind of
3038
- // prompt. Enter approves (turn Plan off and execute); Esc keeps planning.
3340
+ // Plan review: shown once the model hands over a <plan> in Plan mode. Same box
3341
+ // shape as the tool approval above so the two read as the same kind of prompt.
3342
+ // It shows WHAT the plan will touch (step/file counts and the files named), so
3343
+ // approving is an informed decision rather than a guess from prose.
3039
3344
  if (state.planPending && !dialog) {
3040
- const pp = state.planPending;
3041
3345
  const promptW = insideW;
3042
3346
  const innerW = Math.max(1, promptW - 2);
3043
3347
  const bar = (ch) => col(ch, C.border);
3044
3348
  const boxRow = (content) => bar('│') + ' ' + fitAnsi(content, innerW) + ' ' + bar('│');
3045
3349
  lines.push(bar('╭' + '─'.repeat(promptW) + '╮'));
3046
- lines.push(boxRow(col('Approve', C.white + C.bold) + ' ' + col('this plan?', C.cyan + C.bold)));
3047
- // The plan is ALREADY shown in its table above - do not repeat it here.
3048
- lines.push(boxRow(col('Enter to approve & execute | Esc to keep planning', C.gray)));
3350
+ lines.push(boxRow(col('Review', C.white + C.bold) + ' ' + col('this plan', C.cyan + C.bold)));
3351
+ // The plan body is ALREADY shown in its table above — summarise it here.
3352
+ let review = [];
3353
+ try { review = reviewLines(state.planPending.plan, state.workspace); } catch { review = []; }
3354
+ for (const r of review) lines.push(boxRow(col(r, C.gray)));
3049
3355
  lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
3050
3356
  }
3051
3357
 
@@ -3059,7 +3365,11 @@ export function composeFrame(state, cols, rows) {
3059
3365
  // it rather than added on top — otherwise the box came out 2 columns short.
3060
3366
  const CPAD = ' ';
3061
3367
  const cInner = Math.max(1, insideW - 1);
3062
- lines.push(CPAD + col('╭' + '─'.repeat(cInner) + '╮', C.border));
3368
+ // In shell mode the whole editor frame shifts to the violet shellMode hue
3369
+ // (same token kimi-code uses for its bash-mode border), so it reads as
3370
+ // "shell mode" rather than the cyan prompt frame.
3371
+ const shellBorder = state.inputMode === 'bash' ? C.shellMode : C.border;
3372
+ lines.push(CPAD + col('╭' + '─'.repeat(cInner) + '╮', shellBorder));
3063
3373
  for (let ri = 0; ri < composer.rows.length; ri++) {
3064
3374
  addHit(lines.length, 2, cInner, { kind: 'composerRow', rowIdx: ri });
3065
3375
  // Colour the prompt glyph like the transcript's user marker (cyan) and keep
@@ -3081,7 +3391,10 @@ export function composeFrame(state, cols, rows) {
3081
3391
  const highlighted = (sel && sel.anchor !== sel.head)
3082
3392
  ? highlightSelection(textPart, textStart, sel.anchor, sel.head)
3083
3393
  : textPart;
3084
- rowAnsi = ' ' + col(pre, C.cyan) + C.white +
3394
+ // The `!` (shell mode) prefix is BOLD: it is a mode marker, not punctuation,
3395
+ // and it should out-weigh the `❯` prompt glyph at a glance.
3396
+ const preCol = state.inputMode === 'bash' ? (C.shellMode + C.bold) : C.cyan;
3397
+ rowAnsi = ' ' + col(pre, preCol) + C.white +
3085
3398
  fitAnsi(highlighted, Math.max(1, textW - visualCol(pre))) + ' ';
3086
3399
  } else {
3087
3400
  const textStart = rowMeta.start != null ? rowMeta.start : 0;
@@ -3091,10 +3404,10 @@ export function composeFrame(state, cols, rows) {
3091
3404
  rowAnsi = ' ' + C.white + fitAnsi(highlighted, textW) + ' ';
3092
3405
  }
3093
3406
  lines.push(
3094
- CPAD + col('│', C.cyan) + rowAnsi + C.reset + col('│', C.cyan)
3407
+ CPAD + col('│', shellBorder) + rowAnsi + C.reset + col('│', shellBorder)
3095
3408
  );
3096
3409
  }
3097
- lines.push(CPAD + col('╰' + '─'.repeat(cInner) + '╯', C.border));
3410
+ lines.push(CPAD + col('╰' + '─'.repeat(cInner) + '╯', shellBorder));
3098
3411
  }
3099
3412
 
3100
3413
 
@@ -3122,21 +3435,28 @@ export function composeFrame(state, cols, rows) {
3122
3435
  if (state.mentionOpen && state.mentionList.length && !dialog && !state.menuOpen) {
3123
3436
  const totalMatches = state.mentionList.length;
3124
3437
  const sel = state.mentionSel + 1;
3125
- const off = state.mentionOffset || 0;
3438
+ // Render the VISIBLE WINDOW, but decide "is this row the selected one" by the
3439
+ // entry's GLOBAL index. The window start is re-derived here from the selection
3440
+ // rather than trusted from state: whatever offset the caller left behind, the
3441
+ // selected row is inside the slice, so it is always the one that highlights.
3442
+ const off = windowOffset(state.mentionSel, state.mentionOffset, totalMatches);
3126
3443
  const shown = state.mentionList.slice(off, off + MAX_MENU);
3127
3444
  shown.forEach((it, j) => {
3128
- const selected = off + j === state.mentionSel;
3445
+ const globalIndex = off + j;
3446
+ const selected = globalIndex === state.mentionSel;
3129
3447
  const mark = selected ? col('❯ ', C.cyan) : ' ';
3130
3448
  const rel = String(it.rel || it.label || '');
3131
- const cut = rel.lastIndexOf('/') + 1;
3132
- const dirPart = cut > 0 ? rel.slice(0, cut) : '';
3133
- const basePart = rel.slice(cut);
3134
- const label = (dirPart ? col(dirPart, C.gray) : '')
3135
- + col(basePart, selected ? (C.cyan + C.bold) : C.gray);
3136
- const kind = it.isDir ? col(' dir', C.gray) : '';
3137
- const pad = Math.max(2, 40 - visualCol(rel) - visualCol(kind));
3138
- addHit(lines.length, 1, insideW, { kind: 'mentionItem', index: off + j });
3139
- lines.push(col('│' + fitAnsi(mark + label + kind + ' '.repeat(pad), insideW) + '│', C.border));
3449
+ const isDir = !!it.isDir;
3450
+ // The SELECTED row highlights its WHOLE path; unselected rows use the dim
3451
+ // colour. Previously the part before the last `/` was always rendered dim,
3452
+ // so a selected `src/tools/` showed bright `tools/` next to a grey `src/` —
3453
+ // even though the very same `src/` was bright when it was selected itself.
3454
+ // That inconsistency is what the per-segment split caused; there is no
3455
+ // longer a split, so the row reads as one selected (or unselected) item.
3456
+ const label = col(rel, selected ? (C.cyan + C.bold) : C.gray);
3457
+ const pad = Math.max(2, 40 - visualCol(rel));
3458
+ addHit(lines.length, 1, insideW, { kind: 'mentionItem', index: globalIndex });
3459
+ lines.push(col('│' + fitAnsi(mark + label + ' '.repeat(pad), insideW) + '│', C.border));
3140
3460
  });
3141
3461
  lines.push(col('│' + fitAnsi(col(`(${sel}/${totalMatches}) Enter or Tab to insert · Esc to dismiss`, C.gray), insideW) + '│', C.border));
3142
3462
  }
@@ -3182,6 +3502,28 @@ export function composeFrame(state, cols, rows) {
3182
3502
  if (agentTasks > 0) parts.push(col(`[${agentTasks} agent${agentTasks === 1 ? '' : 's'} running]`, C.spring));
3183
3503
  }
3184
3504
  if (state.slCwd !== false && cwd) parts.push(col(cwd, C.gray));
3505
+ // GIT badge: branch, then the working-tree diff totals. Placed AFTER the path so
3506
+ // it reads as "where I am → what branch → how much is uncommitted". Rendered
3507
+ // from state._gitInfo, which a 15s timer and turn-end refresh — the render path
3508
+ // itself never spawns git (see refreshGitInfo).
3509
+ if (state.slGit !== false) {
3510
+ const gi = state._gitInfo;
3511
+ if (gi && gi.branch) {
3512
+ // Branch name: pale yellow + bold. Branch and its diff are ONE part, joined
3513
+ // by a SINGLE space, so it reads as `main +1 -1` (tight). The outer
3514
+ // parts.join(' ') uses two spaces, so this still separates the git badge
3515
+ // from the path/model with two spaces while keeping branch↔diff at one.
3516
+ let badge = C.bold + col(gi.branch, C.branch);
3517
+ if (gi.insertions || gi.deletions) {
3518
+ // Two colours so the direction is readable at a glance: +N green, -N red.
3519
+ const bits = [];
3520
+ if (gi.insertions) bits.push(col(`+${gi.insertions}`, C.green));
3521
+ if (gi.deletions) bits.push(col(`-${gi.deletions}`, C.red));
3522
+ badge += ' ' + bits.join(' ');
3523
+ }
3524
+ parts.push(badge);
3525
+ }
3526
+ }
3185
3527
  const statusLeft = parts.join(' ');
3186
3528
  const statusRight = (state.slTips !== false && state.tip) ? col(state.tip, C.gray) : '';
3187
3529
  lines.push(justify(statusLeft, statusRight, w));
@@ -3405,6 +3747,10 @@ export function makeState({ cfg, session, opts }) {
3405
3747
  hoverHit: null,
3406
3748
  input: '',
3407
3749
  caret: 0,
3750
+ // 'prompt' (talk to the model) or 'bash' (run the line in the shell). Typing
3751
+ // `!` on an empty composer flips this; the `!` itself never enters `input`.
3752
+ // See composerInput() for why that matters.
3753
+ inputMode: 'prompt',
3408
3754
  composerSel: null,
3409
3755
  pastes: new Map(),
3410
3756
  pasteCounter: 0,
@@ -3445,6 +3791,13 @@ export function makeState({ cfg, session, opts }) {
3445
3791
  addDirs: [],
3446
3792
  tasks: {},
3447
3793
  slMode: true, slModel: true, slEffort: true, slCwd: true, slTasks: true, slTips: true,
3794
+ // Git badge in the status line: branch + working-tree diff totals. Refreshed
3795
+ // on a TTL, never per frame — each refresh costs a few git subprocesses and
3796
+ // composeFrame runs on every keystroke.
3797
+ slGit: true,
3798
+ _gitInfo: null, // { branch, insertions, deletions, changed, untracked }
3799
+ _gitAt: 0, // last refresh time (ms)
3800
+ _gitCwd: '', // directory the cached info belongs to
3448
3801
  picker: null,
3449
3802
  pickerQuery: '',
3450
3803
  pickerCategory: null, // active filter category (null = "All")
@@ -3491,21 +3844,48 @@ function stripTipPrefix(s) {
3491
3844
  }
3492
3845
 
3493
3846
  export function reconstructChat(s) {
3494
- const chat = [];
3495
- for (const m of (s && s.messages) || []) {
3847
+ // Shell commands the user ran with `!` are shown on resume at the position they
3848
+ // originally ran. Each entry's `anchor` is how many REAL conversation messages
3849
+ // (session.messages items) preceded it, so we inject it beside that message.
3850
+ // They live in session.shellHistory, never in session.messages — the model
3851
+ // never sees them.
3852
+ const shellByAnchor = new Map();
3853
+ const shells = (s && s.shellHistory) || [];
3854
+ for (const sh of shells) {
3855
+ const a = Number(sh.anchor != null ? sh.anchor : 0);
3856
+ if (!shellByAnchor.has(a)) shellByAnchor.set(a, []);
3857
+ shellByAnchor.get(a).push(sh);
3858
+ }
3859
+ // Emit the shell block for anchor `a` (command + its output) onto `out`.
3860
+ const emitShells = (out, a) => {
3861
+ for (const sh of shellByAnchor.get(a) || []) {
3862
+ out.push({ role: 'bash', text: String(sh.cmd || '') });
3863
+ if (sh.result) out.push({ role: 'tool_result', text: sh.result, failed: sh.ok === false });
3864
+ }
3865
+ };
3866
+
3867
+ const out = [];
3868
+ const msgs = (s && s.messages) || [];
3869
+ for (let mi = 0; mi < msgs.length; mi++) {
3870
+ const m = msgs[mi];
3496
3871
  const role = m.role;
3497
3872
  const text = typeof m.content === 'string' ? m.content : '';
3498
- if (role === 'user') chat.push({ role: 'user', text });
3873
+ // Inject shell commands that anchored BEFORE this message (in the slot after
3874
+ // the previous message).
3875
+ emitShells(out, mi);
3876
+ if (role === 'user') out.push({ role: 'user', text });
3499
3877
  else if (role === 'assistant') {
3500
- chat.push({ role: 'assistant', text });
3878
+ out.push({ role: 'assistant', text });
3501
3879
  for (const tc of (m.toolCalls || [])) {
3502
- chat.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
3880
+ out.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
3503
3881
  }
3504
3882
  } else if (role === 'tool') {
3505
- chat.push({ role: 'tool_result', text });
3883
+ out.push({ role: 'tool_result', text });
3506
3884
  }
3507
3885
  }
3508
- return chat;
3886
+ // Any shell commands anchored at/past the end land at the very tail.
3887
+ emitShells(out, msgs.length);
3888
+ return out;
3509
3889
  }
3510
3890
 
3511
3891
  export function pickerFiltered(state) {
@@ -3537,22 +3917,49 @@ export function refreshMenu(state) {
3537
3917
  return;
3538
3918
  }
3539
3919
  const prefix = val.slice(1).toLowerCase();
3920
+ // `/skill:` and `/skill:<partial>` list the installed skills as menu entries so
3921
+ // they are tab-completable exactly like a built-in command. The entry's `name`
3922
+ // is the full `skill:<name>` token, so accepting it types the whole thing.
3923
+ if (prefix.startsWith(SKILL_PREFIX)) {
3924
+ const partial = prefix.slice(SKILL_PREFIX.length);
3925
+ state.menuList = skillCompletions(partial).map((s) => ({
3926
+ name: s.insert,
3927
+ description: s.description || 'skill',
3928
+ _skill: true,
3929
+ }));
3930
+ state.menuSel = Math.min(state.menuSel, Math.max(0, state.menuList.length - 1));
3931
+ state.menuOpen = state.menuList.length > 0;
3932
+ ensureMenuVisible(state);
3933
+ return;
3934
+ }
3540
3935
  state.menuList = allCommands().filter((c) => c.name.startsWith(prefix));
3541
3936
  state.menuSel = Math.min(state.menuSel, Math.max(0, state.menuList.length - 1));
3542
3937
  state.menuOpen = state.menuList.length > 0;
3543
3938
  ensureMenuVisible(state);
3544
3939
  }
3545
3940
 
3941
+ // Scroll a fixed-height selection window so the selected row stays visible.
3942
+ // Shared by the `/` menu and the `@` candidate list: both render at most
3943
+ // MAX_MENU rows starting at `offset`, so a selection outside that window would
3944
+ // highlight NOTHING — which is exactly the "some entries never highlight" bug
3945
+ // the @ list had (it force-reset offset to 0 on every keystroke while the
3946
+ // selection could sit anywhere).
3947
+ export function windowOffset(sel, offset, count, max = MAX_MENU) {
3948
+ if (!count) return 0;
3949
+ const s = Math.max(0, Math.min(sel || 0, count - 1));
3950
+ let off = Math.max(0, Math.min(offset || 0, Math.max(0, count - max)));
3951
+ if (s < off) off = s;
3952
+ else if (s > off + max - 1) off = Math.max(0, s - max + 1);
3953
+ return off;
3954
+ }
3955
+
3546
3956
  function ensureMenuVisible(state) {
3547
3957
  if (!state.menuList.length) return;
3548
- const off = state.menuOffset || 0;
3549
- const last = off + MAX_MENU - 1;
3550
- if (state.menuSel < off) state.menuOffset = state.menuSel;
3551
- else if (state.menuSel > last) state.menuOffset = Math.max(0, state.menuSel - MAX_MENU + 1);
3958
+ state.menuOffset = windowOffset(state.menuSel, state.menuOffset, state.menuList.length);
3552
3959
  }
3553
3960
 
3554
3961
  // ---- command dispatch ----
3555
- async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, renderFrame) {
3962
+ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, renderFrame) {
3556
3963
  const { addChat, openPicker, openForm, notice, sendPrompt, quit, saveSession, openEditor } = h;
3557
3964
  const entry = findCommand(cmdRaw);
3558
3965
  const cmd = entry ? entry.name : String(cmdRaw || '').replace(/^\//, '');
@@ -3587,6 +3994,17 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3587
3994
 
3588
3995
  case 'plan': {
3589
3996
  const a = raw.toLowerCase();
3997
+ // `/plan saved` lists the plans persisted to <workspace>/.hncode/plans.
3998
+ if (a === 'saved' || a === 'list') {
3999
+ const items = listPlans(state.workspace || cfg.workspace);
4000
+ if (!items.length) { say('No saved plans yet. Approve a plan in Plan mode to save it.'); return; }
4001
+ h.openPanel('hncode — saved plans', [
4002
+ `${items.length} plan(s) in ${plansDir(state.workspace || cfg.workspace)}:`,
4003
+ '',
4004
+ ...items.map((p) => ` ${p.name} ${new Date(p.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')}`),
4005
+ ]);
4006
+ return;
4007
+ }
3590
4008
  if (a === 'clear') { state.plan = false; state.planPath = null; persist(); app('Plan cleared.'); return; }
3591
4009
  if (a === 'on') state.plan = true;
3592
4010
  else if (a === 'off') state.plan = false;
@@ -3618,7 +4036,8 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3618
4036
  { label: 'provider', sub: 'manage AI providers' },
3619
4037
  { label: 'statusline', sub: 'configure status line items' },
3620
4038
  { label: 'add-dir', sub: 'add an additional workspace directory' },
3621
- { label: 'auto-update', sub: 'check npm for updates at startup + every 30 min' },
4039
+ { label: 'auto-update', sub: `check npm for updates at startup + every 30 min — currently ${cfg.autoUpdate ? 'on' : 'off'}` },
4040
+ { label: 'auto-compact', sub: `summarize older history at 85% of the window — currently ${cfg.autoCompact === false ? 'off' : 'on'}` },
3622
4041
  ],
3623
4042
  onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
3624
4043
  });
@@ -3961,7 +4380,6 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3961
4380
  const cwd = path.resolve(state.cwd || state.workspace || process.cwd());
3962
4381
  // Filter to only show sessions from the current workspace.
3963
4382
  const list = all.filter((s) => {
3964
- if (!Array.isArray(s.messages) || s.messages.length === 0) return false;
3965
4383
  const sessionCwd = s.workspace ? path.resolve(s.workspace) : null;
3966
4384
  return sessionCwd === cwd;
3967
4385
  });
@@ -3974,12 +4392,18 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3974
4392
  id: s.id,
3975
4393
  })),
3976
4394
  onPick: (it) => {
3977
- const hit = list.find((s) => s.id === it.id);
3978
- if (hit) {
4395
+ const meta = list.find((s) => s.id === it.id);
4396
+ if (meta) {
4397
+ // The list only carries METADATA (no messages — readSessionMeta strips
4398
+ // them). Assigning it straight onto `session` wiped the transcript, so
4399
+ // the resumed session appeared empty or stale. Load the FULL session
4400
+ // file (like --continue / --resume do), then merge its settings.
4401
+ const full = sess.loadSession(meta.id);
4402
+ if (!full) { app(`Could not load session ${meta.id}`); return true; }
3979
4403
  const keepModel = cfg.model;
3980
4404
  const keepInner = cfg.innerModel;
3981
4405
  const keepProvider = cfg.provider;
3982
- Object.assign(session, hit);
4406
+ Object.assign(session, full);
3983
4407
  cfg.model = keepModel;
3984
4408
  cfg.innerModel = keepInner;
3985
4409
  cfg.provider = keepProvider;
@@ -3996,7 +4420,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3996
4420
  state.ctxPercent = usagePercent(approx, state.ctxMax);
3997
4421
 
3998
4422
  const dur = session.lastTurnMs ? ` · last turn ${fmtDuration(session.lastTurnMs)}` : '';
3999
- app(`Resumed ${hit.id} (${hit.title || 'untitled'}) · ${(session.messages || []).length} messages.${dur}`);
4423
+ app(`Resumed ${meta.id} (${meta.title || 'untitled'}) · ${(session.messages || []).length} messages.${dur}`);
4000
4424
  }
4001
4425
  return true;
4002
4426
  },
@@ -4414,6 +4838,11 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4414
4838
  h.openPanel('hncode — commands', [
4415
4839
  ...allCommands().map((c) => ` /${c.name.padEnd(14)} ${(c.argumentHint || '').padEnd(26)} ${c.desc}`),
4416
4840
  '',
4841
+ 'Skills',
4842
+ ` /${SKILL_PREFIX}<name> run an installed skill (Tab completes the name)`,
4843
+ ' /import-skill <file.md> add or overwrite a skill from a Markdown file',
4844
+ ' /skills list installed skills',
4845
+ '',
4417
4846
  'Shortcuts',
4418
4847
  ' Enter send the message',
4419
4848
  ' Ctrl-J insert a newline',
@@ -4421,6 +4850,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4421
4850
  ' Ctrl+Shift+V paste the clipboard (multi-line pastes collapse)',
4422
4851
  ' ↑ / ↓ input history (empty composer) · scroll the chat',
4423
4852
  ' / open the command menu · Tab completes',
4853
+ ' ! on an empty prompt: switch to shell mode (Esc/Backspace to leave)',
4424
4854
  ' Esc cancel the menu / dialog · interrupt the turn',
4425
4855
  ' Ctrl+E open config.toml in editor',
4426
4856
  ' Ctrl-B move a running Bash command to the background',
@@ -4478,6 +4908,22 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4478
4908
  : 'Auto-update OFF');
4479
4909
  return;
4480
4910
  }
4911
+ case 'auto-compact': {
4912
+ // Toggle /auto-compact. Persists to config.toml so it survives restarts.
4913
+ const a = raw.toLowerCase();
4914
+ let want;
4915
+ if (a === 'on') want = true;
4916
+ else if (a === 'off') want = false;
4917
+ else want = cfg.autoCompact === false; // flip from the current state
4918
+ try { setConfigString('auto_compact', want ? 'true' : 'false'); }
4919
+ catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
4920
+ cfg.autoCompact = want;
4921
+ cfg.raw.auto_compact = want;
4922
+ app(want
4923
+ ? 'Auto-compaction ON — older history is summarized once a request reaches 85% of the context window'
4924
+ : 'Auto-compaction OFF — history is no longer trimmed automatically (use /compact manually)');
4925
+ return;
4926
+ }
4481
4927
  case 'update': {
4482
4928
  // Manual update check. This is the ONLY place an update is ever reported;
4483
4929
  // the automatic background check stays completely silent.
@@ -4498,16 +4944,19 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4498
4944
  case 'version': app(`hncode v${VERSION}`); return;
4499
4945
 
4500
4946
  case 'mcp': {
4501
- const mcpFile = path.join(os.homedir(), '.hncode', 'mcp.json');
4502
- let doc = { mcpServers: {} };
4503
- try { doc = JSON.parse(fs.readFileSync(mcpFile, 'utf8')); } catch {}
4504
- const names = Object.keys(doc.mcpServers || {});
4505
- say(names.length
4506
- ? `MCP servers (${names.length}):\n` + names.map((n) => {
4507
- const e = doc.mcpServers[n];
4508
- return ` ${n} ${e.url ? e.url : `${e.command || ''} ${(e.args || []).join(' ')}`.trim()}`;
4509
- }).join('\n')
4510
- : 'No MCP servers configured. Run /mcp-config to add one.');
4947
+ // Show REAL connection state (from startup) alongside the configured
4948
+ // servers, plus each server's discovered tools. `/mcp tools` lists them.
4949
+ const cfgMcp = loadMcpConfig(state.workspace || cfg.workspace);
4950
+ const conns = getLiveConnections();
4951
+ const lines = describeServers(cfgMcp, conns);
4952
+ if (cfgMcp.errors.length) lines.push('', 'Config problems:', ...cfgMcp.errors.map((e) => ` ! ${e}`));
4953
+ const showTools = String(raw || '').toLowerCase() === 'tools';
4954
+ if (showTools) {
4955
+ const found = conns.filter((c) => c.ok).map((c) => ` ${c.name}: ${c.toolCount} tool(s)`);
4956
+ lines.push('', found.length ? 'Discovered tools:' : 'No servers connected — they connect at startup.');
4957
+ lines.push(...found);
4958
+ }
4959
+ h.openPanel('hncode — mcp', lines);
4511
4960
  return;
4512
4961
  }
4513
4962
  case 'mcp-config': {
@@ -4542,29 +4991,38 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4542
4991
  return;
4543
4992
  }
4544
4993
 
4545
- case 'statusline':
4994
+ case 'statusline':
4546
4995
  openPicker({
4547
4996
  title: 'Status line items',
4548
- items: [
4549
- { label: 'permission mode', kind: 'toggle', isOn: state.slMode !== false },
4550
- { label: 'model name', kind: 'toggle', isOn: state.slModel !== false },
4551
- { label: 'thinking effort', kind: 'toggle', isOn: state.slEffort !== false },
4552
- { label: 'current directory', kind: 'toggle', isOn: state.slCwd !== false },
4553
- { label: 'background tasks', kind: 'toggle', isOn: state.slTasks !== false },
4554
- { label: 'rotating tips', kind: 'toggle', isOn: state.slTips !== false },
4555
- ],
4997
+ items: (() => {
4998
+ // `sub` must be set here, not only in onPick: the picker renders
4999
+ // item.sub, so without an initial value every toggle showed a blank
5000
+ // state until the user toggled it once.
5001
+ const toggles = [
5002
+ ['permission mode', 'slMode'],
5003
+ ['model name', 'slModel'],
5004
+ ['thinking effort', 'slEffort'],
5005
+ ['current directory', 'slCwd'],
5006
+ ['git branch & diff', 'slGit'],
5007
+ ['background tasks', 'slTasks'],
5008
+ ['rotating tips', 'slTips'],
5009
+ ];
5010
+ return toggles.map(([label, key]) => {
5011
+ const isOn = state[key] !== false;
5012
+ return { label, key, kind: 'toggle', isOn, sub: isOn ? 'on' : 'off' };
5013
+ });
5014
+ })(),
4556
5015
  hint: '↑↓ navigate · Enter toggle · Esc close',
4557
5016
  onPick: (it) => {
4558
5017
  it.isOn = !it.isOn;
4559
5018
  it.sub = it.isOn ? 'on' : 'off';
4560
- const key = { 'permission mode': 'slMode', 'model name': 'slModel', 'thinking effort': 'slEffort', 'current directory': 'slCwd', 'background tasks': 'slTasks', 'rotating tips': 'slTips' }[it.label];
4561
- state[key] = it.isOn;
5019
+ state[it.key] = it.isOn;
5020
+ if (it.key === 'slGit') refreshGitInfo(state, { force: true });
4562
5021
  app(`Status line: ${it.label} → ${it.isOn ? 'shown' : 'hidden'}`);
4563
5022
  return false;
4564
5023
  },
4565
5024
  });
4566
5025
  return;
4567
-
4568
5026
  case 'export-md': {
4569
5027
  const msgs = session.messages || [];
4570
5028
  if (!msgs.length) { appErr('Nothing to export (empty session).'); return; }
@@ -4704,6 +5162,12 @@ case 'statusline':
4704
5162
  sess.saveSession(newSession);
4705
5163
  // Update state to use the new session.
4706
5164
  session = newSession;
5165
+ // The directory changed, so the status line (path + git badge) must follow.
5166
+ // It previously kept showing the OLD path because state.cwd was never
5167
+ // updated here.
5168
+ state.cwd = target;
5169
+ state.workspace = target;
5170
+ refreshGitInfo(state, { force: true });
4707
5171
  state.chat = reconstructChat(session);
4708
5172
  state.scroll = 0;
4709
5173
  state.rounds = session.rounds || 0;
@@ -4739,6 +5203,326 @@ case 'statusline':
4739
5203
  h.openPanel('hncode — plugins', lines);
4740
5204
  return;
4741
5205
  }
5206
+ case 'skills': {
5207
+ const parts = raw.split(/\s+/).filter(Boolean);
5208
+ const sub = (parts[0] || '').toLowerCase();
5209
+ if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
5210
+ const name = parts[1];
5211
+ if (!name) { appErr('Usage: /skills remove <name>'); return; }
5212
+ const ok = deleteSkill(name);
5213
+ app(ok ? `Skill removed: ${normalizeSkillName(name)}` : `No such skill: ${name}`);
5214
+ return;
5215
+ }
5216
+ if (sub === 'list' || sub === '') {
5217
+ const items = listSkills();
5218
+ if (!items.length) {
5219
+ say(`No skills installed. Add one with /import-skill <file.md> (dir: ${skillsDir()}).`);
5220
+ return;
5221
+ }
5222
+ h.openPanel('hncode — skills', [
5223
+ `Installed skills (${items.length}) — activate with /${SKILL_PREFIX}<name>:`,
5224
+ '',
5225
+ ...items.map((s) => ` /${SKILL_PREFIX}${s.name}${s.description ? ' — ' + s.description : ''}`),
5226
+ '',
5227
+ `Directory: ${skillsDir()}`,
5228
+ 'Remove one with /skills remove <name>.',
5229
+ ]);
5230
+ return;
5231
+ }
5232
+ appErr(`Usage: /skills [list] | remove <name>`);
5233
+ return;
5234
+ }
5235
+ case 'import-skill': {
5236
+ // Usage: /import-skill <file.md> [name]
5237
+ const parts = raw.split(/\s+/).filter(Boolean);
5238
+ const file = parts[0];
5239
+ const nameArg = parts[1];
5240
+ if (!file) { appErr('Usage: /import-skill <file.md> [name]'); return; }
5241
+ const res = importSkill(file, nameArg);
5242
+ if (!res.ok) { appErr(res.error); return; }
5243
+ app(`${res.replaced ? 'Skill overwritten' : 'Skill imported'}: ${res.name} → ${res.path}`);
5244
+ say(`Activate it with /${SKILL_PREFIX}${res.name}`);
5245
+ return;
5246
+ }
5247
+ case 'hooks': {
5248
+ const workspace = state.workspace || cfg.workspace || process.cwd();
5249
+ const parts = raw.split(/\s+/).filter(Boolean);
5250
+ const sub = (parts[0] || '').toLowerCase();
5251
+ if (sub === 'test') {
5252
+ const event = parts[1];
5253
+ if (!event || !HOOK_EVENTS.includes(event)) {
5254
+ appErr(`Usage: /hooks test <event> (events: ${HOOK_EVENTS.join(', ')})`);
5255
+ return;
5256
+ }
5257
+ const { hooks } = loadHooks(workspace);
5258
+ const res = await runShellHooks({ hooks }, event, { toolName: 'Bash', toolArgs: { command: 'echo hi' }, prompt: 'test' }, workspace);
5259
+ if (!(hooks[event] || []).length) { app(`No ${event} hooks configured.`); return; }
5260
+ const lines = res.results.map((r) => {
5261
+ const head = r.ok ? `✔ ${r.entry.command}` : `✖ ${r.entry.command} (${r.message})`;
5262
+ const body = (r.stderr || r.stdout || '').trim();
5263
+ return body ? `${head}\n${body}` : head;
5264
+ });
5265
+ h.openPanel(`hncode — hooks: ${event} (test)`, [
5266
+ `Ran ${res.results.length} hook(s)${res.blocked ? ' — the tool WOULD be blocked' : ''}.`,
5267
+ '',
5268
+ ...lines,
5269
+ ]);
5270
+ return;
5271
+ }
5272
+ const { hooks, errors } = loadHooks(workspace);
5273
+ const lines = describeHooks({ hooks });
5274
+ if (errors.length) lines.push('', 'Config problems:', ...errors.map((e) => ` ! ${e}`));
5275
+ h.openPanel('hncode — hooks', lines);
5276
+ return;
5277
+ }
5278
+ case 'git': {
5279
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5280
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5281
+ refreshGitInfo(state, { force: true }); // keep the badge in sync with this view
5282
+ const branch = gitmod.currentBranch(cwd);
5283
+ const s = gitmod.status(cwd);
5284
+ const info = gitmod.parseRemote(gitmod.remoteUrl(cwd));
5285
+ const lines = [
5286
+ `branch: ${branch || '(unknown)'}`,
5287
+ `head: ${gitmod.headSha(cwd)}`,
5288
+ info ? `remote: ${info.owner}/${info.repo} (${info.host})` : 'remote: (none)',
5289
+ '',
5290
+ `changes (${s.ok ? s.entries.length : '?'}):`,
5291
+ ...gitmod.statusLines(cwd).map((l) => ' ' + l),
5292
+ ];
5293
+ h.openPanel('hncode — git', lines);
5294
+ return;
5295
+ }
5296
+ case 'add': {
5297
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5298
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5299
+ // `/add` stages everything (`git add -A`); `/add <path> ...` stages just
5300
+ // those paths. This is the explicit staging step /commit does implicitly.
5301
+ const paths = raw.split(/\s+/).filter(Boolean);
5302
+ const r = gitmod.stage(paths, cwd);
5303
+ if (!r.ok) { appErr(`git add failed: ${r.error}`); return; }
5304
+ const staged = gitmod.stagedFiles(cwd);
5305
+ app(paths.length ? `Staged ${paths.length} path(s).` : 'Staged all changes.');
5306
+ say(`Index now holds ${staged.length} file(s):\n` + staged.map((f) => ' ' + f).join('\n'));
5307
+ return;
5308
+ }
5309
+ case 'diff': {
5310
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5311
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5312
+ const staged = /--staged|--cached/.test(raw);
5313
+ const path = raw.replace(/--staged|--cached/g, '').trim();
5314
+ const r = gitmod.diffText(cwd, { staged, path: path || undefined });
5315
+ if (!r.ok) { appErr(`git diff failed: ${r.error}`); return; }
5316
+ if (r.empty) { say(staged ? 'No staged changes.' : 'No uncommitted changes.'); return; }
5317
+ say((staged ? 'Staged changes' : 'Uncommitted changes') + (path ? ` in ${path}` : '') + ':\n' + r.text);
5318
+ return;
5319
+ }
5320
+ case 'log': {
5321
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5322
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5323
+ const parts = raw.split(/\s+/).filter(Boolean);
5324
+ const count = parts[0] && /^\d+$/.test(parts[0]) ? parts.shift() : undefined;
5325
+ const path = parts.join(' ') || undefined;
5326
+ const r = gitmod.logText(cwd, { count, path });
5327
+ if (!r.ok) { appErr(`git log failed: ${r.error}`); return; }
5328
+ if (r.empty) { say('No commits yet.'); return; }
5329
+ say(`Recent commits${path ? ` for ${path}` : ''}:\n` + r.text);
5330
+ return;
5331
+ }
5332
+ case 'push': {
5333
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5334
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5335
+ const parts = raw.split(/\s+/).filter(Boolean);
5336
+ const force = parts.includes('-f') || parts.includes('--force');
5337
+ const branchArg = parts.find((x) => !x.startsWith('-')) || '';
5338
+ const branch = branchArg || gitmod.currentBranch(cwd);
5339
+ const upstream = gitmod.hasUpstream(cwd, branch);
5340
+ const state2 = gitmod.aheadBehind(cwd, branch);
5341
+ const detail = `${branch}${upstream ? '' : ' (no upstream — will set one)'}${state2 ? ` — ${state2}` : ''}`;
5342
+ // Push is OUTBOUND: always confirm first, and say exactly what will happen.
5343
+ h.openPicker({
5344
+ title: `Push ${branch}?`,
5345
+ items: [
5346
+ { label: 'Push', sub: `git push ${force ? '--force-with-lease ' : ''}origin ${detail}` },
5347
+ { label: 'Cancel', sub: 'do not push' },
5348
+ ],
5349
+ searchable: false,
5350
+ hint: '↑↓ navigate · Enter select · Esc cancel',
5351
+ onPick: (it) => {
5352
+ if (it.label !== 'Push') { app('Push cancelled.'); return true; }
5353
+ const r = gitmod.push(cwd, { branch: branchArg || undefined, force, setUpstream: !upstream });
5354
+ if (!r.ok) appErr(`Push failed: ${r.error}`);
5355
+ else { app(`Pushed ${branch}.`); say(`git push output:\n${r.output}`); }
5356
+ return true;
5357
+ },
5358
+ });
5359
+ return;
5360
+ }
5361
+ case 'stash': {
5362
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5363
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5364
+ const parts = raw.split(/\s+/).filter(Boolean);
5365
+ const sub = (parts[0] || '').toLowerCase();
5366
+ if (!sub || sub === 'list') {
5367
+ const list = gitmod.stashList(cwd);
5368
+ say(list.length ? `Stashes (${list.length}):\n` + list.map((s) => ' ' + s).join('\n') : 'No stashes.');
5369
+ return;
5370
+ }
5371
+ if (sub === 'push' || sub === 'save') {
5372
+ const msg = parts.slice(1).join(' ') || undefined;
5373
+ const r = gitmod.stashPush(cwd, msg);
5374
+ if (!r.ok) { appErr(`git stash failed: ${r.error}`); return; }
5375
+ app('Stashed changes.'); say(r.output || 'Changes stashed.');
5376
+ return;
5377
+ }
5378
+ if (sub === 'pop') {
5379
+ const r = gitmod.stashPop(cwd);
5380
+ if (!r.ok) { appErr(`git stash pop failed: ${r.error}`); return; }
5381
+ app('Restored from stash.'); say(r.output || 'Stash popped.');
5382
+ return;
5383
+ }
5384
+ appErr('Usage: /stash [list] | push [message] | pop');
5385
+ return;
5386
+ }
5387
+ case 'rebase': {
5388
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5389
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5390
+ const onto = String(raw || '').trim();
5391
+ if (!onto) { appErr('Usage: /rebase <branch>'); return; }
5392
+ const cur = gitmod.currentBranch(cwd);
5393
+ // Rewrites history on the current branch: confirm before running.
5394
+ h.openPicker({
5395
+ title: `Rebase ${cur} onto ${onto}?`,
5396
+ items: [
5397
+ { label: 'Rebase', sub: `git rebase ${onto}` },
5398
+ { label: 'Cancel', sub: 'leave the branch as is' },
5399
+ ],
5400
+ searchable: false,
5401
+ hint: '↑↓ navigate · Enter select · Esc cancel',
5402
+ onPick: (it) => {
5403
+ if (it.label !== 'Rebase') { app('Rebase cancelled.'); return true; }
5404
+ const r = gitmod.rebase(cwd, onto);
5405
+ if (!r.ok) appErr(`Rebase failed (resolve conflicts, then /rebase --continue or git rebase --abort): ${r.error}`);
5406
+ else { app(`Rebased ${cur} onto ${onto}.`); say(r.output || 'Rebase complete.'); }
5407
+ return true;
5408
+ },
5409
+ });
5410
+ return;
5411
+ }
5412
+ case 'commit': {
5413
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5414
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5415
+ // `/commit <message>` commits what is already staged, staging everything
5416
+ // when the index is empty (the common "commit my changes" case).
5417
+ // `/commit` with NO message hands the job to the model, which can read the
5418
+ // diff and draft a message — that is the point of having an agent.
5419
+ const message = String(raw || '').trim();
5420
+ if (!message) {
5421
+ const s = gitmod.status(cwd);
5422
+ if (s.ok && !s.entries.length) { app('Nothing to commit — working tree is clean.'); return; }
5423
+ const branch = gitmod.currentBranch(cwd);
5424
+ const pending = gitmod.statusLines(cwd).slice(0, 40).join('\n');
5425
+ await submit(
5426
+ `Create a git commit for the current changes.\n\n`
5427
+ + `Repository state (branch: ${branch}):\n${pending}\n\n`
5428
+ + `Steps: run \`git diff\` (and \`git status\`) to review the changes, stage the relevant files, then commit with a concise message `
5429
+ + `that explains the intent. Match the repository's existing commit-message style. Do not push. `
5430
+ + `Finally, report the commit hash and a one-line summary of what was committed.`,
5431
+ );
5432
+ return;
5433
+ }
5434
+ if (!gitmod.hasStagedChanges(cwd)) {
5435
+ const st = gitmod.stage([], cwd);
5436
+ if (!st.ok) { appErr(`git add failed: ${st.error}`); return; }
5437
+ }
5438
+ const res = gitmod.commit(message, cwd);
5439
+ if (!res.ok) { appErr(`Commit failed: ${res.error}`); return; }
5440
+ app(`Committed ${res.sha}: ${message.split('\n')[0]}`);
5441
+ const files = res.stdout.split('\n').slice(0, 8).join('\n');
5442
+ say(`Committed ${res.sha}\n${files}`);
5443
+ return;
5444
+ }
5445
+ case 'branch': {
5446
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5447
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5448
+ const parts = raw.split(/\s+/).filter(Boolean);
5449
+ const sub = (parts[0] || '').toLowerCase();
5450
+ if (!sub || sub === 'list') {
5451
+ const cur = gitmod.currentBranch(cwd);
5452
+ const branches = gitmod.listBranches(cwd);
5453
+ if (!branches.length) { app(`No branches yet (current: ${cur}).`); return; }
5454
+ say(`Branches (current: ${cur}):\n` + branches.map((b) => ` ${b === cur ? '* ' : ' '}${b}`).join('\n'));
5455
+ return;
5456
+ }
5457
+ if (sub === '-c' || sub === 'create' || sub === 'new') {
5458
+ const name = parts[1];
5459
+ if (!name) { appErr('Usage: /branch -c <name> [from]'); return; }
5460
+ const r = gitmod.createBranch(name, cwd, parts[2]);
5461
+ if (!r.ok) { appErr(`Could not create branch: ${r.error}`); return; }
5462
+ app(`Switched to new branch: ${name}`);
5463
+ return;
5464
+ }
5465
+ const r = gitmod.switchBranch(parts[0], cwd);
5466
+ if (!r.ok) { appErr(`Could not switch branch: ${r.error}`); return; }
5467
+ app(`Switched to branch: ${parts[0]}`);
5468
+ return;
5469
+ }
5470
+ case 'pr': {
5471
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5472
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5473
+ const base = String(raw || '').trim() || 'main';
5474
+ const url = gitmod.prUrl(cwd, base);
5475
+ if (!url) { appErr('No git remote named "origin" — cannot build a PR URL.'); return; }
5476
+ const branch = gitmod.currentBranch(cwd);
5477
+ say(`Open a pull request for ${branch} → ${base}:\n ${url}`);
5478
+ app('PR link copied to the transcript (Ctrl+Shift+C to copy).');
5479
+ return;
5480
+ }
5481
+ case 'cache': {
5482
+ // Prompt caching: explicit cache breakpoints for the stable prefix (tools +
5483
+ // system + conversation head). Saves input tokens on every turn after the
5484
+ // first. Anthropic gets cache_control markers; OpenAI-compatible providers
5485
+ // get a stable prompt_cache_key.
5486
+ const a = String(raw || '').toLowerCase();
5487
+ const want = a === 'on' ? true : a === 'off' ? false : !(cfg.promptCache !== false);
5488
+ cfg.promptCache = want;
5489
+ try { setConfigString('prompt_cache', want ? 'true' : 'false'); } catch { /* best-effort persist */ }
5490
+ app(`Prompt caching: ${want ? 'ON' : 'OFF'}`);
5491
+ say(want
5492
+ ? 'The stable prefix (tools + system + conversation head) is marked cacheable.\n'
5493
+ + 'Providers bill cached input at a fraction of the normal rate. Effective\n'
5494
+ + 'for: anthropic (cache_control), openai-compatible (prompt_cache_key).'
5495
+ : 'Prompt caching disabled — every request sends the full prompt as fresh input.');
5496
+ return;
5497
+ }
5498
+ case 'worktree': {
5499
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5500
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5501
+ const parts = raw.split(/\s+/).filter(Boolean);
5502
+ const sub = (parts[0] || '').toLowerCase();
5503
+ if (!sub || sub === 'list') {
5504
+ const list = gitmod.listWorktrees(cwd);
5505
+ say(`Worktrees (${list.length}):\n` + list.map((w) => ` ${w.path} ${w.branch ? '[' + w.branch + ']' : w.detached ? '(detached)' : ''}`).join('\n'));
5506
+ return;
5507
+ }
5508
+ if (sub === 'add') {
5509
+ const dir = parts[1];
5510
+ if (!dir) { appErr('Usage: /worktree add <dir> [branch]'); return; }
5511
+ const r = gitmod.addWorktree(dir, parts[2], cwd);
5512
+ if (!r.ok) { appErr(`Could not add worktree: ${r.error}`); return; }
5513
+ app(r.reused ? `Worktree already exists: ${r.path}` : `Worktree created: ${r.path}${r.branch ? ` [${r.branch}]` : ''}`);
5514
+ return;
5515
+ }
5516
+ if (sub === 'remove' || sub === 'rm') {
5517
+ const dir = parts[1];
5518
+ if (!dir) { appErr('Usage: /worktree remove <dir>'); return; }
5519
+ const r = gitmod.removeWorktree(dir, cwd, { force: parts.includes('--force') });
5520
+ app(r.ok ? `Worktree removed: ${dir}` : `Could not remove worktree: ${r.error}`);
5521
+ return;
5522
+ }
5523
+ appErr('Usage: /worktree [list] | add <dir> [branch] | remove <dir>');
5524
+ return;
5525
+ }
4742
5526
  case 'logout': {
4743
5527
  const pr = (cfg.raw && cfg.raw.providers) || {};
4744
5528
  const names = Object.keys(pr);
@@ -4774,7 +5558,23 @@ case 'statusline':
4774
5558
  }
4775
5559
  case 'exit': quit(); return;
4776
5560
 
4777
- default:
5561
+ default: {
5562
+ // A skill activation: /skill:<name> [extra args]. The skill's body is sent
5563
+ // as the turn's prompt, so it behaves like a saved prompt fragment the user
5564
+ // can recall by name (tab-completed in the composer).
5565
+ const rawName = String(cmdRaw || '').replace(/^\/+/, '');
5566
+ if (rawName.toLowerCase().startsWith(SKILL_PREFIX)) {
5567
+ const name = normalizeSkillName(rawName);
5568
+ const sk = readSkill(name);
5569
+ if (!sk || !sk.body) {
5570
+ appErr(`No such skill: ${name}. Use /skills to list them, or /import-skill to add one.`);
5571
+ return;
5572
+ }
5573
+ const extra = String(raw || '').trim();
5574
+ const prompt = skillPrompt(sk) + (extra ? `\n\n${extra}` : '');
5575
+ await submit(prompt);
5576
+ return;
5577
+ }
4778
5578
  // Check if this is a plugin-registered command.
4779
5579
  if (isPluginCommand(cmd)) {
4780
5580
  const pc = findCommand(cmd);
@@ -4787,6 +5587,7 @@ case 'statusline':
4787
5587
  // Unknown command: show error message
4788
5588
  appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
4789
5589
  return;
5590
+ }
4790
5591
  }
4791
5592
  }
4792
5593
 
@@ -4865,6 +5666,11 @@ function commandPathsAreInside(state, cmd, cwd) {
4865
5666
  function applyModel(state, cfg, next) {
4866
5667
  cfg.model = next.model; cfg.innerModel = next.innerModel; cfg.provider = next.provider;
4867
5668
  cfg.baseUrl = next.baseUrl; cfg.endpoint = next.endpoint; cfg.apiKey = next.apiKey; cfg.protocol = next.protocol;
5669
+ // Carry the resolved context window over too. resolveProvider/resolveModelArg
5670
+ // compute `maxContextTokens` for the NEW model, but it lives on the returned
5671
+ // object — without copying it here, cfg (and the gauge below) kept the previous
5672
+ // model's window after a switch.
5673
+ if (next.maxContextTokens != null) cfg.maxContextTokens = next.maxContextTokens;
4868
5674
  state.model = cfg.model; state.provider = cfg.provider;
4869
5675
  state.modelLabel = modelLabel(cfg) || cfg.model || '';
4870
5676
  state.seenThinking = false;
@@ -5172,6 +5978,9 @@ export async function startTUI(opts) {
5172
5978
  if (out) stdout.write(out);
5173
5979
  }
5174
5980
  function renderFrame() { paintNow(); }
5981
+ // Let module-level helpers (refreshGitInfo) ask for a repaint once their async
5982
+ // data lands, without holding a reference to this closure.
5983
+ state._requestRender = renderFrame;
5175
5984
  // No frame-rate cap: paint on the next event-loop turn. Calls made within the
5176
5985
  // same tick still coalesce (paintScheduled), so a burst of stream deltas
5177
5986
  // produces one frame per tick at full speed instead of being throttled to
@@ -5310,8 +6119,10 @@ export async function startTUI(opts) {
5310
6119
  });
5311
6120
  state.mentionList = ranked.slice(0, 200).map((r) => r.f);
5312
6121
  state.mentionSel = Math.min(state.mentionSel || 0, Math.max(0, state.mentionList.length - 1));
5313
- // Hitting Tab again after the list closed should start from the top.
5314
- state.mentionOffset = 0;
6122
+ // Keep the window on the selected row. This used to reset the offset to 0,
6123
+ // which left a selection beyond MAX_MENU rows invisible — the renderer only
6124
+ // walks [offset, offset+MAX_MENU), so nothing at all appeared highlighted.
6125
+ state.mentionOffset = windowOffset(state.mentionSel, state.mentionOffset, state.mentionList.length);
5315
6126
  state.mentionOpen = state.mentionList.length > 0;
5316
6127
  }
5317
6128
 
@@ -5537,9 +6348,58 @@ export async function startTUI(opts) {
5537
6348
  // and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
5538
6349
  warmClipboard();
5539
6350
  setTip(state);
6351
+ // Fill the git badge — asynchronously, AFTER the first paint. It used to run
6352
+ // before renderFrame() with a blocking spawnSync git, which added ~330ms to
6353
+ // startup; now the frame draws at once and the badge appears when git answers.
6354
+ refreshGitInfo(state, { force: true });
5540
6355
  renderFrame();
5541
6356
 
6357
+ // MCP: connect in the BACKGROUND, now that the UI is on screen. Each server
6358
+ // gets up to CONNECT_TIMEOUT_MS, so doing this before the first paint would
6359
+ // stall the whole TUI; instead we start it and report the outcome as a notice
6360
+ // ("MCP: connected to …") when it lands. Tools registered here become visible
6361
+ // to the model on its next turn.
6362
+ if (typeof opts.mcpConnect === 'function') {
6363
+ Promise.resolve()
6364
+ .then(() => opts.mcpConnect())
6365
+ .then((res) => {
6366
+ const servers = (res && res.servers) || [];
6367
+ if (!servers.length) return;
6368
+ // The notice line is a SINGLE status-line row, so report all servers in
6369
+ // one message — looping notice() per server only left the last one
6370
+ // visible. Failures are listed too, and the whole notice turns red when
6371
+ // any server failed.
6372
+ const okServers = servers.filter((s) => s.ok);
6373
+ const bad = servers.filter((s) => !s.ok);
6374
+ const parts = [];
6375
+ if (okServers.length) {
6376
+ const tools = okServers.reduce((n, s) => n + (s.toolCount || 0), 0);
6377
+ const names = okServers.map((s) => s.name).join(', ');
6378
+ parts.push(`MCP connected: ${names} (${tools} tool${tools === 1 ? '' : 's'})`);
6379
+ }
6380
+ if (bad.length) {
6381
+ parts.push(`MCP failed: ${bad.map((s) => s.name).join(', ')}`);
6382
+ }
6383
+ notice(parts.join(' · '), bad.length ? 'error' : 'info');
6384
+ // A connecting server may have changed the tool list; repaint so any
6385
+ // tool-count readout is current.
6386
+ renderFrame();
6387
+ })
6388
+ .catch(() => { /* MCP must never break the session */ });
6389
+ }
6390
+
5542
6391
  const tipTimer = setInterval(() => { setTip(state); renderFrame(); }, TIP_INTERVAL);
6392
+ // Git badge: a fixed 15s cadence. Deliberately NOT tied to the frame loop (that
6393
+ // spawned git per keystroke) and NOT run mid-turn (the agent is still writing;
6394
+ // the turn-end refresh picks up its edits). Repaints only when something
6395
+ // actually changed, so an idle session stays quiet.
6396
+ const gitTimer = setInterval(() => {
6397
+ if (state.running) return;
6398
+ const before = state._gitInfo && `${state._gitInfo.branch}|${state._gitInfo.insertions}|${state._gitInfo.deletions}`;
6399
+ refreshGitInfo(state, { force: true });
6400
+ const after = state._gitInfo && `${state._gitInfo.branch}|${state._gitInfo.insertions}|${state._gitInfo.deletions}`;
6401
+ if (before !== after) renderFrame();
6402
+ }, GIT_REFRESH_MS);
5543
6403
  const spinTimer = setInterval(() => {
5544
6404
  if (!state.running) return;
5545
6405
  // Monotonic tick counter — do NOT wrap it at SPINNER.length, or the
@@ -5671,6 +6531,14 @@ export async function startTUI(opts) {
5671
6531
  function dispatchHit(hb) {
5672
6532
  if (!hb) return false;
5673
6533
  switch (hb.kind) {
6534
+ case 'mentionItem': {
6535
+ // Mirror menuItem: clicking an @candidate selects AND accepts it. The
6536
+ // case was simply missing, so a click fell through to `default: false`
6537
+ // and did nothing at all.
6538
+ state.mentionSel = hb.index;
6539
+ acceptMention();
6540
+ return true;
6541
+ }
5674
6542
  case 'menuItem': {
5675
6543
  state.menuSel = hb.index;
5676
6544
  const cmd = state.menuList[hb.index];
@@ -5981,16 +6849,25 @@ export async function startTUI(opts) {
5981
6849
  function insertComposerPaste(raw) {
5982
6850
  const text = String(raw == null ? '' : raw).replace(/\r\n?/g, '\n');
5983
6851
  if (!text) return false;
5984
- const lineCount = text.split('\n').length;
5985
- let insert = text;
6852
+ // A paste starting with `!` into an EMPTY prompt means "run this in the
6853
+ // shell" — enter shell mode and drop the marker, exactly like typing it. Same
6854
+ // rule as kimi-code's editor, so a copied `!git status` behaves identically.
6855
+ let body = text;
6856
+ if (state.inputMode !== 'bash' && (state.input || '').length === 0 && body.startsWith('!')) {
6857
+ state.inputMode = 'bash';
6858
+ body = body.replace(/^!+/, '');
6859
+ }
6860
+ const lineCount = body.split('\n').length;
6861
+ let insert = body;
5986
6862
  if (lineCount > 1) {
5987
6863
  const id = ++state.pasteCounter;
5988
- state.pastes.set(id, { text, lines: lineCount });
6864
+ state.pastes.set(id, { text: body, lines: lineCount });
5989
6865
  insert = pasteMarker(id, lineCount);
5990
6866
  }
5991
6867
  state.input = (state.input || '').slice(0, state.caret) + insert + (state.input || '').slice(state.caret);
5992
6868
  state.caret += insert.length;
5993
- refreshMenu(state);
6869
+ // Shell mode offers no `/` commands or `@` files.
6870
+ if (state.inputMode !== 'bash') refreshMenu(state);
5994
6871
  return true;
5995
6872
  }
5996
6873
  // Async so the notice can be painted BEFORE the clipboard read: a cold
@@ -6151,25 +7028,20 @@ export async function startTUI(opts) {
6151
7028
  // keep drafting while deciding.
6152
7029
  if (t.key === 'c-c') return;
6153
7030
  }
6154
- // Plan approval: the model produced a <plan> and Plan mode is waiting.
6155
- // Enter = approve (Plan turns off, the plan is sent back to be executed).
6156
- // Esc = keep planning, do NOT execute.
7031
+ // Plan review: the model produced a <plan> and Plan mode is waiting.
7032
+ // Enter/Space = approve & execute, e = edit first, Esc = keep planning.
6157
7033
  if (state.planPending) {
6158
- if (t.key === 'enter' || t.key === ' ') {
6159
- const resolve = state.planPending.resolve;
6160
- state.planPending = null;
6161
- resolve(true);
6162
- renderFrame();
6163
- return;
6164
- }
6165
- if (t.key === 'escape') {
7034
+ const settle = (outcome) => {
6166
7035
  const resolve = state.planPending.resolve;
6167
7036
  state.planPending = null;
6168
- resolve(false);
7037
+ resolve(outcome);
6169
7038
  renderFrame();
6170
- return;
6171
- }
7039
+ };
7040
+ if (t.key === 'enter' || t.key === ' ') { settle('approve'); return; }
7041
+ if (t.key === 'escape') { settle('keep'); return; }
7042
+ if (t.ch === 'e' || t.ch === 'E') { settle('edit'); return; }
6172
7043
  if (t.key === 'c-c') return; // do not exit while a plan is under review
7044
+ return; // swallow other keys while reviewing
6173
7045
  }
6174
7046
  if (t.key === 'c-c') {
6175
7047
  // An open overlay consumes Ctrl+C: close it instead of interrupting the
@@ -6407,7 +7279,14 @@ export async function startTUI(opts) {
6407
7279
  // transcript. Without this guard the running block below stole those keys.
6408
7280
  // The modal editor (e.g. from /set-system-prompt) is included so that Esc and
6409
7281
  // arrow keys are handled by it, not by the running-turn interrupt/scroll logic.
6410
- const overlayOpen = !!(state.picker || state.form || state.panel || state.menuOpen || state.editor);
7282
+ //
7283
+ // `mentionOpen` MUST be in this list: it is the inline `@file` candidate list,
7284
+ // which is a keyboard-owning overlay just like the `/` menu. Leaving it out
7285
+ // meant that while the agent was streaming, ↑/↓ scrolled the transcript
7286
+ // (instead of moving the selection) and Esc interrupted the turn (instead of
7287
+ // dismissing the list) — the keys never reached the mention block below.
7288
+ const overlayOpen = !!(state.picker || state.form || state.panel || state.menuOpen
7289
+ || state.mentionOpen || state.editor);
6411
7290
  if (state.running && !overlayOpen) {
6412
7291
  if (t.key === 'escape') {
6413
7292
  if (state.agent) state.agent.interrupt();
@@ -6422,13 +7301,32 @@ export async function startTUI(opts) {
6422
7301
  renderFrame(); return;
6423
7302
  }
6424
7303
  if (t.key === 'up' || t.key === 'down') {
7304
+ // ↑/↓ only scroll when the COMPOSER has no use for the key. While the
7305
+ // agent streams you are usually typing the next instruction, so a
7306
+ // multi-line draft must still move its caret — swallowing the key for
7307
+ // scrolling unconditionally is what made ↑/↓ "always scroll". When the
7308
+ // caret is already on the first/last row the key falls through to the
7309
+ // scroll below, so both behaviours are available.
7310
+ const insideW = Math.max(0, dims().cols - 2);
7311
+ const layout = composerLayout(state, insideW - 3);
7312
+ const dir = t.key === 'down' ? 1 : -1;
7313
+ const targetRow = layout.caretRow + dir;
7314
+ if (targetRow >= 0 && targetRow < layout.rows.length) {
7315
+ const starts = rowStartOffsets(state.input || '', layout.rows.length, insideW);
7316
+ const colInRow = Math.max(0, layout.caretCol - 1);
7317
+ const targetStart = starts[targetRow] != null ? starts[targetRow] : 0;
7318
+ state.caret = Math.min((state.input || '').length, targetStart + colInRow);
7319
+ state.composerSel = null;
7320
+ renderFrame(); return;
7321
+ }
6425
7322
  scrollChat(state, t.key === 'up' ? 3 : -3);
6426
7323
  renderFrame(); return;
6427
7324
  }
6428
7325
  }
6429
7326
  else {
6430
7327
  if (t.key === 'up' || t.key === 'down') {
6431
- const overlay = state.picker || state.form || state.panel || state.menuOpen || state.editor;
7328
+ const overlay = state.picker || state.form || state.panel || state.menuOpen
7329
+ || state.mentionOpen || state.editor;
6432
7330
  const cur = state.input || '';
6433
7331
  if (!overlay && cur === '' && state.history.length) {
6434
7332
  if (state.historyIdx === -1) state.historyIdx = state.history.length;
@@ -6698,6 +7596,13 @@ export async function startTUI(opts) {
6698
7596
  }
6699
7597
 
6700
7598
  if (t.key === 'escape') {
7599
+ // In shell mode a bare Esc on an empty buffer exits the mode (nothing to
7600
+ // clear); with text present it falls through and clears as usual.
7601
+ if (state.inputMode === 'bash' && state.input.length === 0) {
7602
+ state.inputMode = 'prompt';
7603
+ renderFrame();
7604
+ return;
7605
+ }
6701
7606
  state.menuOpen = false; state.menuList = []; state.menuSel = 0;
6702
7607
  state.menuOffset = 0;
6703
7608
  state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
@@ -6705,6 +7610,7 @@ export async function startTUI(opts) {
6705
7610
  state.input = ''; state.caret = 0;
6706
7611
  state.pastes.clear(); state.pasteCounter = 0;
6707
7612
  state.composerSel = null;
7613
+ state.inputMode = 'prompt';
6708
7614
  refreshMenu(state);
6709
7615
  renderFrame();
6710
7616
  return;
@@ -6726,8 +7632,7 @@ export async function startTUI(opts) {
6726
7632
  if (n) {
6727
7633
  state.mentionSel = (state.mentionSel + (t.key === 'down' ? 1 : n - 1)) % n;
6728
7634
  // Keep the selection inside the visible window.
6729
- if (state.mentionSel < state.mentionOffset) state.mentionOffset = state.mentionSel;
6730
- else if (state.mentionSel >= state.mentionOffset + MAX_MENU) state.mentionOffset = state.mentionSel - MAX_MENU + 1;
7635
+ state.mentionOffset = windowOffset(state.mentionSel, state.mentionOffset, n);
6731
7636
  renderFrame();
6732
7637
  }
6733
7638
  return;
@@ -6798,6 +7703,16 @@ export async function startTUI(opts) {
6798
7703
  renderFrame();
6799
7704
  return;
6800
7705
  }
7706
+ // The @ candidate list scrolls its own selection, exactly like /menu —
7707
+ // otherwise a wheel notch scrolled the transcript behind an open list.
7708
+ if (state.mentionOpen && state.mentionList.length) {
7709
+ const n = state.mentionList.length;
7710
+ const cur = Math.min(state.mentionSel, n - 1);
7711
+ state.mentionSel = (cur + (t.key === 'wheeldown' ? 1 : n - 1)) % n;
7712
+ state.mentionOffset = windowOffset(state.mentionSel, state.mentionOffset, n);
7713
+ renderFrame();
7714
+ return;
7715
+ }
6801
7716
  scrollChat(state, d);
6802
7717
  renderFrame(); return;
6803
7718
  }
@@ -6914,6 +7829,14 @@ export async function startTUI(opts) {
6914
7829
  return;
6915
7830
  }
6916
7831
  if (t.key === 'backspace') {
7832
+ // In shell mode with an empty buffer there is nothing to delete, so the
7833
+ // Backspace reads as "remove the `!`" and drops back to prompt mode —
7834
+ // exactly the kimi-code behaviour.
7835
+ if (state.inputMode === 'bash' && state.input.length === 0 && !(state.composerSel && state.composerSel.anchor !== state.composerSel.head)) {
7836
+ state.inputMode = 'prompt';
7837
+ renderFrame();
7838
+ return;
7839
+ }
6917
7840
  const sel = state.composerSel;
6918
7841
  if (sel && sel.anchor !== sel.head) {
6919
7842
  const a = Math.min(sel.anchor, sel.head);
@@ -6962,14 +7885,27 @@ export async function startTUI(opts) {
6962
7885
  renderFrame(); return;
6963
7886
  }
6964
7887
  if (t.ch) {
7888
+ // `!` on an EMPTY prompt toggles shell mode instead of being inserted — the
7889
+ // `!` becomes the prefix, so the buffer holds only the command. Mirrors
7890
+ // kimi-code's editor: the marker never enters the text, so the caret never
7891
+ // steps over it and submit never strips it.
7892
+ if (t.ch === '!' && state.inputMode !== 'bash' && state.input.length === 0) {
7893
+ state.inputMode = 'bash';
7894
+ state.composerSel = null;
7895
+ renderFrame();
7896
+ return;
7897
+ }
6965
7898
  state.input = state.input.slice(0, state.caret) + t.ch + state.input.slice(state.caret);
6966
7899
  state.caret++;
6967
7900
  state.composerSel = null;
6968
7901
  // Any printable character can extend or start an `@mention`, so refresh the
6969
7902
  // inline list on every keystroke: typing `@` opens it, and typing after it
6970
- // narrows it — the same live-feedback the `/` menu gives.
6971
- refreshMention();
6972
- if (!state.mentionOpen) refreshMenu(state);
7903
+ // narrows it — the same live-feedback the `/` menu gives. In shell mode
7904
+ // there are no @mentions or slash-commands to offer, so skip both.
7905
+ if (state.inputMode !== 'bash') {
7906
+ refreshMention();
7907
+ if (!state.mentionOpen) refreshMenu(state);
7908
+ }
6973
7909
  renderFrame();
6974
7910
  }
6975
7911
  }
@@ -7037,6 +7973,16 @@ export async function startTUI(opts) {
7037
7973
  }
7038
7974
 
7039
7975
  if (state.history[state.history.length - 1] !== text) state.history.push(text);
7976
+ // Shell passthrough. The MODE is authoritative (the `!` never entered the
7977
+ // buffer); a leading `!` in the text is still honoured so a pasted line like
7978
+ // `!git status` behaves the same as typing it.
7979
+ if (state.inputMode === 'bash' || text.startsWith('!')) {
7980
+ const cmd = text.replace(/^!+/, '').trim();
7981
+ state.inputMode = 'prompt'; // one command per activation, like kimi
7982
+ if (!cmd) { renderFrame(); return; }
7983
+ await runShellCommand(cmd);
7984
+ return;
7985
+ }
7040
7986
  // Commands are handled immediately, never queued: queuing them delayed a
7041
7987
  // /plan or /model until the running turn finished, which is not what
7042
7988
  // typing a command means.
@@ -7061,6 +8007,80 @@ export async function startTUI(opts) {
7061
8007
  await runAgent(text);
7062
8008
  }
7063
8009
 
8010
+ // `!command` — run a shell command directly, without the model.
8011
+ //
8012
+ // The composer path is deliberately bypassed: the command runs through the same
8013
+ // Bash tool the agent uses (so it gets the same timeout, cwd, output
8014
+ // sanitising and truncation), but its result is rendered as a `↳` receipt and
8015
+ // NOT sent to the model. That is the point — a quick `git add -A && git push`
8016
+ // should be instant and predictable, not a round-trip the agent might reword.
8017
+ //
8018
+ // The command is echoed into the transcript so the scrollback shows what was
8019
+ // run, and it is kept out of `session.messages` so it does not pollute the
8020
+ // conversation the model sees.
8021
+ async function runShellCommand(cmd) {
8022
+ const started = Date.now();
8023
+ // Echo the command as a dedicated `bash` role so the transcript renders it
8024
+ // with SHELL MODE styling (violet frame + `!` glyph). It is recorded in
8025
+ // session.shellHistory (persisted, shown on resume) but NEVER in
8026
+ // session.messages, so the model never sees the command.
8027
+ addChat({ role: 'bash', text: cmd });
8028
+ // Record the command for the NEXT session's display. It lives in its own
8029
+ // field, not session.messages, so it survives a restart for the user to see
8030
+ // but is never sent to the model. `anchor` remembers WHERE in the
8031
+ // conversation this shell command ran (how many real messages came before
8032
+ // it), so a resume can put it back in that spot instead of appending it at
8033
+ // the very end. The result/exit is patched once it runs.
8034
+ if (session) {
8035
+ session.shellHistory = session.shellHistory || [];
8036
+ session.shellHistory.push({ cmd, ts: Date.now(), ok: null, anchor: (session.messages || []).length });
8037
+ }
8038
+ state.scroll = 0;
8039
+ state.running = true;
8040
+ state.startAnim = { start: Date.now() };
8041
+ renderFrame();
8042
+
8043
+ let out = '';
8044
+ let failed = false;
8045
+ try {
8046
+ const bash = getTool('Bash');
8047
+ if (!bash) {
8048
+ out = 'Error: the Bash tool is unavailable.';
8049
+ failed = true;
8050
+ } else {
8051
+ const result = await bash.execute(
8052
+ { command: cmd, description: 'shell passthrough' },
8053
+ { ...cfg, cwd: state.cwd || state.workspace, workspace: state.workspace || state.cwd, allowExternal: true },
8054
+ );
8055
+ out = String(result == null ? '' : result);
8056
+ failed = isFailureResult(out, 'Bash');
8057
+ }
8058
+ } catch (e) {
8059
+ out = `Error running command: ${e.message}`;
8060
+ failed = true;
8061
+ } finally {
8062
+ state.running = false;
8063
+ }
8064
+
8065
+ addChat({ role: 'tool_result', text: out, failed });
8066
+ const ms = Date.now() - started;
8067
+ addChat({ role: 'system', text: `[${cmd.split('\n')[0]} — ${failed ? 'failed' : 'done'} in ${fmtDuration(ms)}]` });
8068
+ // Close the tail of the shellHistory entry pushed at the start: mark it
8069
+ // done/failed with a short result preview, then persist the session so the
8070
+ // command (and its outcome) survives a restart.
8071
+ if (session && Array.isArray(session.shellHistory) && session.shellHistory.length) {
8072
+ const last = session.shellHistory[session.shellHistory.length - 1];
8073
+ last.ok = !failed;
8074
+ last.doneAt = Date.now();
8075
+ last.result = String(out || '').slice(0, 500); // keep a peek, bound it
8076
+ try { sess.saveSession(session); } catch { /* best-effort */ }
8077
+ }
8078
+ // A shell command can change the working tree, so refresh the git badge now
8079
+ // rather than waiting for the next TTL tick.
8080
+ refreshGitInfo(state, { force: true });
8081
+ renderFrame();
8082
+ }
8083
+
7064
8084
  // `asSystem` injects the text as a SYSTEM message instead of a user message and
7065
8085
  // skips the user bubble. Used by the approved-plan handoff: the plan is not
7066
8086
  // something the user typed, so it must not appear (or persist) as their message.
@@ -7120,6 +8140,17 @@ export async function startTUI(opts) {
7120
8140
  if (agents) {
7121
8141
  sysText += '\n\n' + agents;
7122
8142
  }
8143
+ // GIT CONTEXT: the current branch and how many files are uncommitted. Cheap
8144
+ // (two git calls) and it stops the model guessing whether the tree is dirty
8145
+ // or which branch it is on. Skipped silently outside a repository.
8146
+ {
8147
+ const gitCwd = state.cwd || state.workspace || cfg.workspace;
8148
+ let gitsum = '';
8149
+ try { gitsum = gitmod.summary(gitCwd); } catch { gitsum = ''; }
8150
+ if (gitsum) {
8151
+ sysText += '\n\nGIT WORKING TREE (this repository, as of now):\n' + gitsum;
8152
+ }
8153
+ }
7123
8154
  let messages = [{ role: 'system', content: sysText }];
7124
8155
  if (session.messages && session.messages.length) {
7125
8156
  for (const m of session.messages) {
@@ -7267,7 +8298,12 @@ export async function startTUI(opts) {
7267
8298
  });
7268
8299
  const agent = new Agent({
7269
8300
  // maxSteps omitted: the agent loop is uncapped (see agent.js).
7270
- cfg, messages, onApproval,
8301
+ // sessionId makes the prompt-cache key stable per session (see cache.js).
8302
+ cfg: { ...cfg, sessionId: (session && session.id) || cfg.sessionId },
8303
+ messages, onApproval,
8304
+ // Shell hooks are re-read per turn so an edit to hooks.json (or a /hooks
8305
+ // change) takes effect on the next message without restarting.
8306
+ hookConfig: loadHooks(cfg.workspace).hooks,
7271
8307
  onEvent: (e) => {
7272
8308
  // Everything the MODEL streams counts toward the tok/s meter: reasoning
7273
8309
  // chunks carry `text` just like answer chunks do.
@@ -7290,16 +8326,19 @@ export async function startTUI(opts) {
7290
8326
  renderSoon();
7291
8327
  return;
7292
8328
  }
8329
+ if (e.type === 'compacting') {
8330
+ // In-progress notice: the summary below is a full model round-trip, so
8331
+ // show that work is happening instead of leaving the turn silent.
8332
+ addChat({ role: 'system', text: `Context at ${fmtTokens(e.before || 0)} — auto-compacting…` });
8333
+ renderSoon();
8334
+ return;
8335
+ }
7293
8336
  if (e.type === 'compacted') {
7294
8337
  state.ctxTokens = e.after || state.ctxTokens;
7295
8338
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
7296
8339
  addChat({ role: 'system', text: `Context auto-compacted (${fmtTokens(e.before || 0)} → ${fmtTokens(e.after || 0)} tokens).` });
7297
8340
  return;
7298
8341
  }
7299
- if (e.type === 'nudge') {
7300
- // Silent internal signal - don't show to user
7301
- return;
7302
- }
7303
8342
  if (e.type === 'incomplete') {
7304
8343
  // Orange: this is a warning, not ordinary system output.
7305
8344
  addChat({ role: 'warn', text: `Stopped: ${e.reason}. The task may be unfinished — send another message to continue.` });
@@ -7323,8 +8362,16 @@ export async function startTUI(opts) {
7323
8362
  if (e.type === 'aborted') {
7324
8363
  const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
7325
8364
  if (t) t.pending = false;
8365
+ // Settle any RUNNING tool rows too. A tool that was mid-flight when the
8366
+ // user hit Esc never gets its tool_result, so its `pending` stayed true
8367
+ // and the `Using <Tool>` name kept pulsing forever. Clearing it drops the
8368
+ // name to plain cyan immediately.
8369
+ for (const m of state.chat) {
8370
+ if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; }
8371
+ }
7326
8372
  // Use a special role that won't show the ✓ icon
7327
8373
  state.chat.push({ role: 'aborted', text: C.red + 'interrupted' + C.reset });
8374
+ renderFrame();
7328
8375
  return;
7329
8376
  }
7330
8377
  if (e.type === 'steer') {
@@ -7349,15 +8396,23 @@ export async function startTUI(opts) {
7349
8396
  if (!dup) {
7350
8397
  state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '', startAt: Date.now() });
7351
8398
  }
7352
- } else if (e.type === 'tool_args') {
7353
8399
  } else if (e.type === 'tool_args') {
7354
8400
  const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id);
7355
8401
  if (entry) {
7356
- entry._argsRaw = (entry._argsRaw || '') + e.chunk;
8402
+ // Incremental scan: each chunk is parsed ONCE and the parse state
8403
+ // carries forward. Re-parsing the whole accumulated blob per chunk was
8404
+ // O(n²) and froze the UI on a large Write (see createArgStream).
8405
+ if (!entry._argStream) entry._argStream = createArgStream();
8406
+ feedArgStream(entry._argStream, e.chunk);
7357
8407
  // Track the key argument as it streams so "Using Bash (cmd)" shows
7358
8408
  // the command DURING the run rather than only once it has finished.
7359
- entry.toolArgs = extractPartialArgs(entry._argsRaw, entry.toolArgs || {});
7360
- if (entry.toolName === 'Write') entry.streamContent = extractJsonString(entry._argsRaw, 'content');
8409
+ entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
8410
+ if (entry.toolName === 'Write') {
8411
+ entry.streamContent = argStreamValue(entry._argStream, 'content');
8412
+ // Incremental line count so the preview's "… N more lines" note is
8413
+ // exact without re-scanning the whole content every frame.
8414
+ entry._streamLineCount = argStreamLineCount(entry._argStream, 'content');
8415
+ }
7361
8416
  }
7362
8417
  renderSoon(); return;
7363
8418
  } else if (e.type === 'tool_output') {
@@ -7487,12 +8542,12 @@ export async function startTUI(opts) {
7487
8542
  if (agent.ctx) {
7488
8543
  agent.ctx._onBackgroundTaskDone = (task) => {
7489
8544
  if (!task) return;
7490
- const status = task.status || 'completed';
7491
- const head = status === 'completed'
7492
- ? `[background agent finished: ${task.taskId}]`
7493
- : `[background agent ${status}: ${task.taskId}${task.stopReason ? ' - ' + task.stopReason : ''}]`;
8545
+ // kimi-style lifecycle card: bullet colour by phase, a headline naming
8546
+ // the kind (agent / bash / question task) and what happened, and a dim
8547
+ // detail line. The task's conclusion is kept as the expandable body.
8548
+ const card = backgroundTaskCard(task);
7494
8549
  const body = String(task.output || '').trim();
7495
- addChat({ role: 'system', text: body ? `${head}\n${body}` : head });
8550
+ addChat({ role: 'bg_task', card, text: body });
7496
8551
  };
7497
8552
  }
7498
8553
  await agent.run();
@@ -7521,20 +8576,60 @@ export async function startTUI(opts) {
7521
8576
  else addChat({ role: 'plan', text: planText });
7522
8577
  if (lastAssistant) {
7523
8578
  lastAssistant.text = String(lastAssistant.text).replace(/<\/?plan>/gi, '').trim();
8579
+ // This is a REPLACEMENT, not an append, so the length-based render-cache
8580
+ // fingerprint could coincide with the previous value and leave a stale
8581
+ // frame. Drop the cache explicitly.
8582
+ lastAssistant._cache = null;
7524
8583
  if (!lastAssistant.text.trim()) {
7525
8584
  const i = state.chat.indexOf(lastAssistant);
7526
8585
  if (i >= 0) state.chat.splice(i, 1);
7527
8586
  }
7528
8587
  }
7529
8588
  // ask / yolo -> the user decides; auto -> approved without asking, which
7530
- // is the whole point of "Never Ask".
8589
+ // is the whole point of "Never Ask". The review resolves to one of
8590
+ // 'approve' | 'edit' | 'keep':
8591
+ // approve — execute the plan as written
8592
+ // edit — open the plan in the editor, then execute the EDITED text
8593
+ // keep — stay in Plan mode, do not execute
7531
8594
  const cur = state.mode || 'ask';
7532
- const ok = cur === 'auto' ? true : await new Promise((resolve) => {
8595
+ let outcome = cur === 'auto' ? 'approve' : await new Promise((resolve) => {
7533
8596
  state.planPending = { plan: planText, resolve };
7534
8597
  renderFrame();
7535
8598
  });
7536
8599
  state.planPending = null;
7537
- if (ok) approvedPlan = planText;
8600
+ if (outcome === 'edit') {
8601
+ // Let the user revise the plan before it is executed. The editor is
8602
+ // synchronous from the agent's point of view: we wait for save.
8603
+ const edited = await new Promise((resolve) => {
8604
+ openEditor({
8605
+ title: 'Review plan — edit before executing',
8606
+ text: planText,
8607
+ caretRow: 0,
8608
+ caretCol: 0,
8609
+ onSave: (value) => resolve(String(value || '').trim()),
8610
+ onCancel: () => resolve(null),
8611
+ });
8612
+ renderFrame();
8613
+ });
8614
+ if (edited) {
8615
+ // Keep the on-screen bubble in sync with the edited text.
8616
+ const bubble = [...state.chat].reverse().find((m) => m.role === 'plan');
8617
+ if (bubble) { bubble.text = edited; bubble._cache = null; }
8618
+ approvedPlan = edited;
8619
+ outcome = 'approve';
8620
+ } else {
8621
+ outcome = 'keep';
8622
+ }
8623
+ }
8624
+ if (outcome === 'approve') {
8625
+ approvedPlan = planText;
8626
+ // Persist the approved plan next to the project so it survives the
8627
+ // session and can be reviewed with the change (or handed to an issue).
8628
+ try {
8629
+ const saved = savePlan(planText, state.workspace || cfg.workspace);
8630
+ if (saved.ok) addChat({ role: 'system', text: `Plan saved: ${saved.path}` });
8631
+ } catch { /* persistence is best-effort */ }
8632
+ }
7538
8633
  }
7539
8634
  }
7540
8635
 
@@ -7577,6 +8672,10 @@ export async function startTUI(opts) {
7577
8672
  // Also clear startAnim if it's still active (should be cleared already by composeFrame)
7578
8673
  state.startAnim = null;
7579
8674
  state.running = false;
8675
+ // The agent may have written or edited files this turn: refresh the git badge
8676
+ // now so the diff totals reflect it immediately, instead of waiting for the
8677
+ // 15s timer.
8678
+ refreshGitInfo(state, { force: true });
7580
8679
 
7581
8680
  if (turnMs > 0) {
7582
8681
  state.lastTurnMs = turnMs;
@@ -7706,6 +8805,7 @@ export async function startTUI(opts) {
7706
8805
  }
7707
8806
  if (proseIdx >= 0) {
7708
8807
  state.chat[proseIdx].text = trimmed;
8808
+ state.chat[proseIdx]._cache = null; // replacement, not append: see fp()
7709
8809
  if (!trimmed.trim()) state.chat.splice(proseIdx, 1); // nothing left: drop it
7710
8810
  } else if (trimmed.trim()) {
7711
8811
  state.chat.unshift({ role: 'assistant', text: trimmed });
@@ -7717,6 +8817,7 @@ export async function startTUI(opts) {
7717
8817
  let last = state.chat[state.chat.length - 1];
7718
8818
  if (last && last.role === 'plan') {
7719
8819
  last.text = planSoFar;
8820
+ last._cache = null; // replacement, not append: see fp()
7720
8821
  } else {
7721
8822
  state.chat.push({ role: 'plan', text: planSoFar });
7722
8823
  }
@@ -7772,6 +8873,10 @@ export async function startTUI(opts) {
7772
8873
  failed: !!(m && m.failed),
7773
8874
  };
7774
8875
  if (m && Array.isArray(m.diff)) out.diff = m.diff;
8876
+ // Keep `card`: a background-task lifecycle card carries its phase/headline/
8877
+ // detail here (see the `bg_task` renderer). Dropping it left the row with the
8878
+ // fallback text instead of what actually happened.
8879
+ if (m && m.card) out.card = m.card;
7775
8880
  return out;
7776
8881
  }
7777
8882
 
@@ -7811,6 +8916,36 @@ export async function startTUI(opts) {
7811
8916
  try { stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l' + alternateScreen(false) + showCursor()); } catch {}
7812
8917
  });
7813
8918
 
8919
+ // REMOTE CONTROL (--control): expose a local socket so a script can queue a
8920
+ // prompt on this running session, query its status, or interrupt it. Opt-in —
8921
+ // nothing listens unless the user asked for it. Failures are non-fatal (a
8922
+ // second instance may already hold the socket).
8923
+ let control = null;
8924
+ if (opts.control != null) {
8925
+ try {
8926
+ control = await startControlServer({
8927
+ prompt: async (text) => { await submit(text); },
8928
+ status: async () => ({
8929
+ busy: !!(state.running),
8930
+ session: (session && session.id) || '',
8931
+ model: cfg.model || '',
8932
+ cwd: state.cwd || cfg.workspace || '',
8933
+ queued: (state.queued || []).length,
8934
+ }),
8935
+ interrupt: async () => { if (state.agent && typeof state.agent.interrupt === 'function') state.agent.interrupt(); },
8936
+ }, { path: opts.control || undefined });
8937
+ if (control.error) {
8938
+ try { stdout.write(`\r\n control socket unavailable: ${control.error}\r\n`); } catch {}
8939
+ } else {
8940
+ addChat({ role: 'system', text: `Remote control listening on ${control.path}` });
8941
+ renderFrame();
8942
+ }
8943
+ } catch (e) {
8944
+ try { stdout.write(`\r\n control socket failed: ${e.message}\r\n`); } catch {}
8945
+ }
8946
+ }
8947
+ if (control && control.server) process.on('exit', () => { try { control.close(); } catch {} });
8948
+
7814
8949
  // Keep the process alive for the whole session. startTUI is async and returns
7815
8950
  // as soon as the TUI is wired up; without a pending promise, main()'s await
7816
8951
  // resolves immediately and Node exits (the "flash of UI then quit" bug).