@hncode/hncode 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.js CHANGED
@@ -28,6 +28,7 @@ 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';
@@ -35,6 +36,12 @@ import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task
35
36
  import { renderSwarmProgress } from './swarm-progress.js';
36
37
  import { sortedTasks, getTask, settleTask, STATUS_LABEL } 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,6 +89,15 @@ 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: 'cache', desc: 'Toggle prompt caching for long stable prefixes (saves input tokens)', priority: 58, argumentHint: '[on|off]' },
85
101
  { name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
86
102
  { name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
87
103
  { name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
@@ -191,6 +207,7 @@ export function extractPlan(text) {
191
207
 
192
208
  export const TIPS = [
193
209
  'Press Esc to interrupt the current turn at any time',
210
+ '!git status runs a shell command directly — no model round-trip',
194
211
  '/init generates an AGENTS.md from your codebase for smarter agent context',
195
212
  'Use /mcp to manage MCP servers for additional tool integration',
196
213
  'The context line shows real-time token usage — watch it grow as the agent works',
@@ -820,8 +837,17 @@ function wrapWithOffsets(text, width) {
820
837
  // not use it — they render their own inputs (search box, form fields).
821
838
  // The prompt glyph matches the transcript's user marker (`❯`) so the composer
822
839
  // reads as "the same kind of thing" as a sent user message.
840
+ //
841
+ // SHELL MODE (`!`) — modelled on kimi-code's editor. Typing `!` on an EMPTY
842
+ // composer flips a mode flag instead of inserting the character; the `!` is
843
+ // rendered as the PREFIX. That is what keeps it from appearing twice:
844
+ // - the buffer holds only the command (`git status`), never the bang
845
+ // - the caret never has to step over a marker it is not editing
846
+ // - submit never has to strip anything
847
+ // The mode ends on Esc/Backspace while the buffer is empty, or on submit.
823
848
  export function composerInput(state) {
824
- return { text: state.input || '', caret: state.caret || 0, prefix: '❯ ' };
849
+ const shell = state.inputMode === 'bash';
850
+ return { text: state.input || '', caret: state.caret || 0, prefix: shell ? '! ' : '❯ ' };
825
851
  }
826
852
 
827
853
  // Marker text for a collapsed multi-line paste, e.g. `[paste #1 +12 lines]`.
@@ -969,7 +995,7 @@ export function caretBlock(row, col) {
969
995
  // prefix; wrapped continuation rows are indented to match. Explicit newlines
970
996
  // start a fresh row. Returns the rows plus the caret's (row, col) within them
971
997
  // (col already includes the leading border column).
972
- function composerLayout(state, insideW) {
998
+ export function composerLayout(state, insideW) {
973
999
  const { text: rawText, caret: rawCaret, prefix } = composerInput(state);
974
1000
  const text = rawText;
975
1001
  const caret = Math.max(0, Math.min(text.length, rawCaret));
@@ -1072,6 +1098,7 @@ function msgColorFor(role, text) {
1072
1098
  if (role === 'system' && typeof text === 'string' && text.startsWith('[turn took')) return C.gray;
1073
1099
  if (role === 'system') return C.teal;
1074
1100
  if (role === 'user') return C.cyan;
1101
+ if (role === 'bash') return C.shellMode; // ! shell-mode echo: violet, like kimi
1075
1102
  if (role === 'warn') return C.orange; // unfinished-turn warning
1076
1103
  if (role === 'queued') return C.gray; // pending, not yet sent
1077
1104
  if (role === 'steer') return C.yellow; // injected into the running turn
@@ -1470,13 +1497,23 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1470
1497
 
1471
1498
  // Write: stream the file content while the model is still emitting it.
1472
1499
  if (name === 'Write' && typeof msg.streamContent === 'string' && msg.streamContent.length) {
1473
- const lines = sanitizeText(msg.streamContent).replace(/\r\n/g, '\n').split('\n');
1500
+ // Only the first MAX lines are drawn, so sanitize+split a bounded PREFIX.
1501
+ // Doing it over the whole accumulated content re-processed every byte on
1502
+ // each frame — O(n²) over the stream, which blocked the main thread on a
1503
+ // large file. The cut is generous (64 KB) to cover MAX long lines; the
1504
+ // line count for the "… N more lines" note is taken from the message,
1505
+ // which tracks the true size cheaply.
1506
+ const SCAN_BYTES = 64 * 1024;
1507
+ const head = msg.streamContent.length > SCAN_BYTES ? msg.streamContent.slice(0, SCAN_BYTES) : msg.streamContent;
1508
+ const lines = sanitizeText(head).replace(/\r\n/g, '\n').split('\n');
1474
1509
  const MAX = 20;
1475
- const wNum = String(lines.length).length;
1510
+ const totalLines = msg._streamLineCount || lines.length;
1511
+ const wNum = String(totalLines).length;
1476
1512
  lines.slice(0, MAX).forEach((ln, i) => {
1477
1513
  out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: i === 0 ? '↳ ' : indent, raw: true });
1478
1514
  });
1479
- if (lines.length > MAX) out.push({ text: col(`… ${lines.length - MAX} more lines`, C.gray), ind: indent, raw: true });
1515
+ const shown = Math.min(lines.length, MAX);
1516
+ if (totalLines > shown) out.push({ text: col(`… ${totalLines - shown} more lines`, C.gray), ind: indent, raw: true });
1480
1517
  }
1481
1518
 
1482
1519
  // The Edit diff is NOT drawn here: it rides on the tool_result message and is
@@ -1668,6 +1705,7 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1668
1705
  switch (msg.role) {
1669
1706
  case 'system': icon = ''; break;
1670
1707
  case 'user': icon = '❯'; break;
1708
+ case 'bash': icon = '!'; break; // shell-mode echo
1671
1709
  case 'warn': icon = '⚑'; break; // ⚑ unfinished-turn warning
1672
1710
  // `queued` keeps the SAME marker as a user message: it is a user message, just
1673
1711
  // not sent yet. Its pending state is shown by the colour (gray) and the queue
@@ -1685,8 +1723,8 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
1685
1723
  // The marker keeps the theme colour (cyan) while a USER message renders its
1686
1724
  // text in white — the whole line used to inherit the role colour, so user
1687
1725
  // 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;
1726
+ const indColor = msg.role === 'user' ? C.cyan : msg.role === 'bash' ? C.shellMode : fg;
1727
+ const bodyFg = msg.role === 'user' ? C.white : msg.role === 'bash' ? C.shellMode : fg;
1690
1728
  const codeFg = C.cyan;
1691
1729
  const lines = [];
1692
1730
  // Only assistant/thinking output benefits from Markdown; user/system/tool
@@ -1959,6 +1997,10 @@ function rowToLine(r) {
1959
1997
  // Pull the string values that are ALREADY complete out of a partially streamed
1960
1998
  // JSON argument blob, so a tool row can render its key argument while the model
1961
1999
  // is still emitting it. Only complete `"key":"value"` pairs are returned.
2000
+ //
2001
+ // NOTE: this rescans the WHOLE blob and is O(n) per call. Callers that see one
2002
+ // call per streamed chunk must use createArgStream()/feedArgStream() instead —
2003
+ // see the comment there for why re-scanning freezes the UI.
1962
2004
  function extractPartialArgs(raw, prev) {
1963
2005
  const out = { ...(prev || {}) };
1964
2006
  const s = String(raw || '');
@@ -1974,6 +2016,186 @@ function extractPartialArgs(raw, prev) {
1974
2016
  return out;
1975
2017
  }
1976
2018
 
2019
+ // Incremental scanner for a streamed JSON arguments blob.
2020
+ //
2021
+ // WHY THIS EXISTS: a tool call's arguments arrive in many small chunks, and the
2022
+ // TUI used to re-parse the ENTIRE accumulated blob on every chunk — one full
2023
+ // regex pass (extractPartialArgs) plus one full character-by-character decode
2024
+ // (extractJsonString) per chunk. That is O(n²) in the argument size, all of it
2025
+ // synchronous on the main thread between network reads, so the UI could not
2026
+ // repaint. Measured: streaming a 500 KB Write cost ~5.2 s of blocked main
2027
+ // thread, and a larger one hung the process.
2028
+ //
2029
+ // This scanner walks the blob ONCE: every character is consumed exactly once,
2030
+ // across all chunks, and the state carries forward. Closed pairs land in
2031
+ // `pairs`; the value still being read is available via argStreamValue() so a
2032
+ // Write can show its content live. Only flat string args are tracked, which is
2033
+ // all the tool schemas use and all the display reads.
2034
+ export function createArgStream() {
2035
+ return {
2036
+ pairs: {}, // complete "key":"value" pairs
2037
+ mode: 'seekKey', // seekKey | inKey | seekColon | seekVal | inVal
2038
+ key: '',
2039
+ cur: '', // key whose value is currently being read
2040
+ val: '',
2041
+ esc: false, // previous char was a backslash inside a string
2042
+ uni: null, // non-null while collecting the 4 hex digits of \uXXXX
2043
+ // Newlines in the value currently being read (or, once closed, in the pair
2044
+ // stored under `lastKey`). Maintained as the value is decoded so the live
2045
+ // Write preview never has to re-scan the whole content to count lines.
2046
+ valLines: 1,
2047
+ lastKey: '',
2048
+ lastKeyLines: 1,
2049
+ };
2050
+ }
2051
+
2052
+ const ARG_ESCAPES = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
2053
+
2054
+ // Feed one chunk into the stream. Returns the stream for chaining.
2055
+ export function feedArgStream(st, chunk) {
2056
+ const s = String(chunk == null ? '' : chunk);
2057
+ if (!st || !s) return st;
2058
+
2059
+ for (let i = 0; i < s.length; i++) {
2060
+ const c = s[i];
2061
+ if (st.mode === 'seekKey') {
2062
+ if (c === '"') { st.mode = 'inKey'; st.key = ''; }
2063
+ continue;
2064
+ }
2065
+ if (st.mode === 'inKey') {
2066
+ if (st.esc) { st.key += c; st.esc = false; continue; }
2067
+ if (c === '\\') { st.esc = true; continue; }
2068
+ if (c === '"') { st.mode = 'seekColon'; continue; }
2069
+ st.key += c;
2070
+ continue;
2071
+ }
2072
+ if (st.mode === 'seekColon') {
2073
+ if (c === ':') { st.mode = 'seekVal'; continue; }
2074
+ if (/\s/.test(c)) continue;
2075
+ // A quoted string that is NOT followed by `:` is not a key — it is an array
2076
+ // element such as `["x"]`. Abandon it and re-scan this char in seekKey so
2077
+ // the scan stays in sync (the original flat regex never matched those, and
2078
+ // treating one as a key desynced everything after it).
2079
+ st.mode = 'seekKey';
2080
+ i--;
2081
+ continue;
2082
+ }
2083
+ if (st.mode === 'seekVal') {
2084
+ if (/\s/.test(c)) continue;
2085
+ if (c === '"') { st.mode = 'inVal'; st.val = ''; st.cur = st.key; st.valLines = 1; continue; }
2086
+ // A non-string value (number/bool/null/object/array). We do not track those,
2087
+ // so resume scanning for the next key. Every char is still consumed once.
2088
+ st.mode = 'seekKey';
2089
+ continue;
2090
+ }
2091
+ // mode === 'inVal'
2092
+ if (st.uni !== null) {
2093
+ st.uni += c;
2094
+ if (st.uni.length === 4) {
2095
+ const cp = parseInt(st.uni, 16) || 0;
2096
+ st.val += String.fromCharCode(cp);
2097
+ if (cp === 10) st.valLines++;
2098
+ st.uni = null;
2099
+ }
2100
+ continue;
2101
+ }
2102
+ if (st.esc) {
2103
+ st.esc = false;
2104
+ if (c === 'u') { st.uni = ''; continue; }
2105
+ const dec = ARG_ESCAPES[c] !== undefined ? ARG_ESCAPES[c] : c;
2106
+ st.val += dec;
2107
+ if (dec === '\n') st.valLines++;
2108
+ continue;
2109
+ }
2110
+ if (c === '\\') { st.esc = true; continue; }
2111
+ if (c === '"') {
2112
+ st.pairs[st.cur] = st.val;
2113
+ st.lastKey = st.cur;
2114
+ st.lastKeyLines = st.valLines;
2115
+ st.cur = ''; st.val = '';
2116
+ st.mode = 'seekKey';
2117
+ continue;
2118
+ }
2119
+ // Fast path: copy the whole run of ordinary characters (no quote, backslash
2120
+ // or newline) in ONE slice instead of char by char. Most of a streamed value
2121
+ // is plain text, and this is what keeps a multi-MB Write from pinning the
2122
+ // main thread in the per-character loop.
2123
+ {
2124
+ let j = i;
2125
+ while (j < s.length) {
2126
+ const cj = s.charCodeAt(j);
2127
+ if (cj === 34 /* " */ || cj === 92 /* \ */ || cj === 10 /* \n */) break;
2128
+ j++;
2129
+ }
2130
+ if (j > i) { st.val += s.slice(i, j); i = j - 1; continue; }
2131
+ }
2132
+ st.val += c;
2133
+ if (c === '\n') st.valLines++;
2134
+ }
2135
+ return st;
2136
+ }
2137
+
2138
+ // The value of `key` — the completed one when the pair has closed, otherwise the
2139
+ // partial text decoded so far (which is what a live Write preview needs).
2140
+ export function argStreamValue(st, key) {
2141
+ if (!st) return '';
2142
+ if (Object.prototype.hasOwnProperty.call(st.pairs, key)) return st.pairs[key];
2143
+ if (st.mode === 'inVal' && st.cur === key) return st.val;
2144
+ return '';
2145
+ }
2146
+
2147
+ // Line count of `key`'s value, tracked incrementally (no re-scan). Used by the
2148
+ // streaming Write preview for its "… N more lines" note.
2149
+ export function argStreamLineCount(st, key) {
2150
+ if (!st) return 1;
2151
+ if (st.mode === 'inVal' && st.cur === key) return st.valLines;
2152
+ if (st.lastKey === key) return st.lastKeyLines;
2153
+ const v = argStreamValue(st, key);
2154
+ if (!v) return 1;
2155
+ let n = 1;
2156
+ for (let i = 0; i < v.length; i++) if (v.charCodeAt(i) === 10) n++;
2157
+ return n;
2158
+ }
2159
+
2160
+ // Cheap fingerprint of a string for render-cache keys.
2161
+ //
2162
+ // It must NOT index or slice a LARGE string: `argStreamValue()` returns a cons
2163
+ // string built by repeated `+=`, and any read beyond `.length` forces V8 to
2164
+ // FLATTEN the whole rope — O(n). Doing that once per streamed chunk is O(n²) and
2165
+ // measured at ~4.5 s for a 10 MB Write, which is exactly the main-thread block
2166
+ // this fix exists to remove.
2167
+ //
2168
+ // So: small strings are embedded verbatim (accurate); large ones are represented
2169
+ // by their LENGTH only, which is O(1) even on a cons string. That is sufficient
2170
+ // here because every string keyed this way is append-only while it is being
2171
+ // streamed (assistant text and a Write's content only ever grow), and is written
2172
+ // once when a turn completes. A large equal-length replacement does not occur on
2173
+ // these fields — the diff/content that can change arbitrarily live elsewhere.
2174
+ const FP_VERBATIM_MAX = 4096;
2175
+ function fp(s) {
2176
+ if (s == null) return '0';
2177
+ const str = String(s);
2178
+ const n = str.length;
2179
+ return n <= FP_VERBATIM_MAX ? n + ':' + str : n + ':';
2180
+ }
2181
+
2182
+ // Fingerprint of a tool-args object for the render-cache key. `JSON.stringify`
2183
+ // was used here and stringified EVERY argument — including a streaming Write's
2184
+ // whole file content — on every frame. The rendered tool line only shows the key
2185
+ // argument (see keyArgument), so hashing each value's length plus a bounded
2186
+ // sample is enough to detect the change, and is O(#keys) instead of O(bytes).
2187
+ function fpArgs(args) {
2188
+ if (!args || typeof args !== 'object') return '0';
2189
+ const keys = Object.keys(args);
2190
+ if (!keys.length) return '0';
2191
+ let out = '';
2192
+ for (const k of keys) {
2193
+ const v = args[k];
2194
+ out += k + '=' + (typeof v === 'string' ? fp(v) : (v === undefined ? 'u' : String(v).slice(0, 32))) + ';';
2195
+ }
2196
+ return out;
2197
+ }
2198
+
1977
2199
  function renderChatLines(state, w) {
1978
2200
  const out = [];
1979
2201
  const expanded = !!state.expanded;
@@ -1988,7 +2210,9 @@ function renderChatLines(state, w) {
1988
2210
  // like the input box it was typed into.
1989
2211
  // `steer` gets NO box: it is injected into the turn already in progress, so a
1990
2212
  // box would falsely suggest a separate turn had started.
1991
- const bordered = msg.role === 'user' || msg.role === 'queued';
2213
+ const bordered = msg.role === 'user' || msg.role === 'queued' || msg.role === 'bash';
2214
+ // A bash-mode echo uses the violet shellMode frame — same hue as kimi.
2215
+ const boxColor = msg.role === 'bash' ? C.shellMode : C.border;
1992
2216
  // The box spans one column less than the full inner width, so the right edge
1993
2217
  // has a 1-column margin (matching the left margin from `PAD`).
1994
2218
  const boxW = innerW - 2; // total box width, including both │
@@ -1996,8 +2220,8 @@ function renderChatLines(state, w) {
1996
2220
  // One column of padding inside each wall, mirroring the composer box.
1997
2221
  const boxPad = Math.min(1, Math.max(0, boxInner - 1));
1998
2222
  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);
2223
+ const topRule = PAD + col('╭' + '─'.repeat(boxInner) + '╮', boxColor);
2224
+ const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯', boxColor);
2001
2225
  // A box always needs its TOP border — including when the message is the first
2002
2226
  // row of the transcript. (The earlier `out.length > 0` guard came from the
2003
2227
  // rule-only version, where a leading divider looked wrong; a box without a
@@ -2019,8 +2243,8 @@ function renderChatLines(state, w) {
2019
2243
  // its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
2020
2244
  // the 80ms tick no longer re-wraps the reasoning preview every frame.
2021
2245
  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)}`
2246
+ const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${fp(msg.text)}\u0000${fp(msg.streamContent)}`
2247
+ + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${fpArgs(msg.toolArgs)}`
2024
2248
  + `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
2025
2249
  let cached = msg._cache;
2026
2250
  if (!cached || cached.key !== key) {
@@ -2038,7 +2262,7 @@ function renderChatLines(state, w) {
2038
2262
  if (bordered) {
2039
2263
  const pad = ' '.repeat(boxPad);
2040
2264
  for (const r of cached.rows) {
2041
- out.push(PAD + col('│', C.border) + pad + fitAnsi(live(r), boxText) + pad + col('│', C.border));
2265
+ out.push(PAD + col('│', boxColor) + pad + fitAnsi(live(r), boxText) + pad + col('│', boxColor));
2042
2266
  }
2043
2267
  } else {
2044
2268
  for (const r of cached.rows) out.push(PAD + live(r));
@@ -2361,6 +2585,35 @@ function sliceAnsi(s, width) {
2361
2585
  return out + (out.includes('\x1b') ? C.reset : '');
2362
2586
  }
2363
2587
 
2588
+ // ---- git status for the status line ----------------------------------------
2589
+ // The badge shows the branch and the uncommitted diff totals. Computing that
2590
+ // costs a handful of git subprocesses, so it is NEVER done from the render path:
2591
+ // composeFrame only reads `state._gitInfo`. It is refreshed from exactly two
2592
+ // places —
2593
+ // 1. a 15s timer while the TUI is idle (see GIT_REFRESH_MS), and
2594
+ // 2. the end of every turn (the agent's edits land there), plus explicit
2595
+ // commands like /git and /move.
2596
+ // Frame-rate polling was the previous design and cost a git spawn per keystroke;
2597
+ // a fixed cadence is both cheaper and more predictable.
2598
+ const GIT_REFRESH_MS = 15000;
2599
+ export function refreshGitInfo(state, opts = {}) {
2600
+ const cwd = state.cwd || state.workspace || '';
2601
+ if (!cwd) return;
2602
+ const now = Date.now();
2603
+ const force = opts.force === true;
2604
+ // Mid-turn the agent is about to write more files, so a poll would only produce
2605
+ // a value that is stale on arrival. The turn-end refresh covers it. Explicit
2606
+ // callers (the timer between turns, /git) still get through with force.
2607
+ if (!force && state.running) return;
2608
+ state._gitCwd = cwd;
2609
+ state._gitAt = now;
2610
+ try {
2611
+ state._gitInfo = gitmod.statuslineInfo(cwd);
2612
+ } catch {
2613
+ state._gitInfo = null; // a git failure must never break rendering
2614
+ }
2615
+ }
2616
+
2364
2617
  // ---- pure frame composer (no TTY side effects) ----
2365
2618
  // kimi-code-cli layout: NO top chrome. Top-to-bottom it is:
2366
2619
  // chat (fills the top) / composer box / [command menu] / status line /
@@ -2369,6 +2622,8 @@ function sliceAnsi(s, width) {
2369
2622
  export function composeFrame(state, cols, rows) {
2370
2623
  const w = Math.max(20, cols | 0);
2371
2624
  const h = Math.max(12, rows | 0);
2625
+ // NOTE: no git call here. The render path only READS state._gitInfo; refreshing
2626
+ // it is driven by a 15s timer and by turn-end (see refreshGitInfo).
2372
2627
  // Full-screen takeovers (kimi's screen-takeover): the /tasks browser and its
2373
2628
  // output viewer own the ENTIRE screen, so they short-circuit the normal
2374
2629
  // layout ? no chat, no composer, no status bar.
@@ -3033,19 +3288,21 @@ export function composeFrame(state, cols, rows) {
3033
3288
  lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
3034
3289
  }
3035
3290
 
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.
3291
+ // Plan review: shown once the model hands over a <plan> in Plan mode. Same box
3292
+ // shape as the tool approval above so the two read as the same kind of prompt.
3293
+ // It shows WHAT the plan will touch (step/file counts and the files named), so
3294
+ // approving is an informed decision rather than a guess from prose.
3039
3295
  if (state.planPending && !dialog) {
3040
- const pp = state.planPending;
3041
3296
  const promptW = insideW;
3042
3297
  const innerW = Math.max(1, promptW - 2);
3043
3298
  const bar = (ch) => col(ch, C.border);
3044
3299
  const boxRow = (content) => bar('│') + ' ' + fitAnsi(content, innerW) + ' ' + bar('│');
3045
3300
  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)));
3301
+ lines.push(boxRow(col('Review', C.white + C.bold) + ' ' + col('this plan', C.cyan + C.bold)));
3302
+ // The plan body is ALREADY shown in its table above — summarise it here.
3303
+ let review = [];
3304
+ try { review = reviewLines(state.planPending.plan, state.workspace); } catch { review = []; }
3305
+ for (const r of review) lines.push(boxRow(col(r, C.gray)));
3049
3306
  lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
3050
3307
  }
3051
3308
 
@@ -3059,7 +3316,11 @@ export function composeFrame(state, cols, rows) {
3059
3316
  // it rather than added on top — otherwise the box came out 2 columns short.
3060
3317
  const CPAD = ' ';
3061
3318
  const cInner = Math.max(1, insideW - 1);
3062
- lines.push(CPAD + col('╭' + '─'.repeat(cInner) + '╮', C.border));
3319
+ // In shell mode the whole editor frame shifts to the violet shellMode hue
3320
+ // (same token kimi-code uses for its bash-mode border), so it reads as
3321
+ // "shell mode" rather than the cyan prompt frame.
3322
+ const shellBorder = state.inputMode === 'bash' ? C.shellMode : C.border;
3323
+ lines.push(CPAD + col('╭' + '─'.repeat(cInner) + '╮', shellBorder));
3063
3324
  for (let ri = 0; ri < composer.rows.length; ri++) {
3064
3325
  addHit(lines.length, 2, cInner, { kind: 'composerRow', rowIdx: ri });
3065
3326
  // Colour the prompt glyph like the transcript's user marker (cyan) and keep
@@ -3081,7 +3342,10 @@ export function composeFrame(state, cols, rows) {
3081
3342
  const highlighted = (sel && sel.anchor !== sel.head)
3082
3343
  ? highlightSelection(textPart, textStart, sel.anchor, sel.head)
3083
3344
  : textPart;
3084
- rowAnsi = ' ' + col(pre, C.cyan) + C.white +
3345
+ // The `!` (shell mode) prefix is BOLD: it is a mode marker, not punctuation,
3346
+ // and it should out-weigh the `❯` prompt glyph at a glance.
3347
+ const preCol = state.inputMode === 'bash' ? (C.shellMode + C.bold) : C.cyan;
3348
+ rowAnsi = ' ' + col(pre, preCol) + C.white +
3085
3349
  fitAnsi(highlighted, Math.max(1, textW - visualCol(pre))) + ' ';
3086
3350
  } else {
3087
3351
  const textStart = rowMeta.start != null ? rowMeta.start : 0;
@@ -3091,10 +3355,10 @@ export function composeFrame(state, cols, rows) {
3091
3355
  rowAnsi = ' ' + C.white + fitAnsi(highlighted, textW) + ' ';
3092
3356
  }
3093
3357
  lines.push(
3094
- CPAD + col('│', C.cyan) + rowAnsi + C.reset + col('│', C.cyan)
3358
+ CPAD + col('│', shellBorder) + rowAnsi + C.reset + col('│', shellBorder)
3095
3359
  );
3096
3360
  }
3097
- lines.push(CPAD + col('╰' + '─'.repeat(cInner) + '╯', C.border));
3361
+ lines.push(CPAD + col('╰' + '─'.repeat(cInner) + '╯', shellBorder));
3098
3362
  }
3099
3363
 
3100
3364
 
@@ -3122,21 +3386,28 @@ export function composeFrame(state, cols, rows) {
3122
3386
  if (state.mentionOpen && state.mentionList.length && !dialog && !state.menuOpen) {
3123
3387
  const totalMatches = state.mentionList.length;
3124
3388
  const sel = state.mentionSel + 1;
3125
- const off = state.mentionOffset || 0;
3389
+ // Render the VISIBLE WINDOW, but decide "is this row the selected one" by the
3390
+ // entry's GLOBAL index. The window start is re-derived here from the selection
3391
+ // rather than trusted from state: whatever offset the caller left behind, the
3392
+ // selected row is inside the slice, so it is always the one that highlights.
3393
+ const off = windowOffset(state.mentionSel, state.mentionOffset, totalMatches);
3126
3394
  const shown = state.mentionList.slice(off, off + MAX_MENU);
3127
3395
  shown.forEach((it, j) => {
3128
- const selected = off + j === state.mentionSel;
3396
+ const globalIndex = off + j;
3397
+ const selected = globalIndex === state.mentionSel;
3129
3398
  const mark = selected ? col('❯ ', C.cyan) : ' ';
3130
3399
  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));
3400
+ const isDir = !!it.isDir;
3401
+ // The SELECTED row highlights its WHOLE path; unselected rows use the dim
3402
+ // colour. Previously the part before the last `/` was always rendered dim,
3403
+ // so a selected `src/tools/` showed bright `tools/` next to a grey `src/` —
3404
+ // even though the very same `src/` was bright when it was selected itself.
3405
+ // That inconsistency is what the per-segment split caused; there is no
3406
+ // longer a split, so the row reads as one selected (or unselected) item.
3407
+ const label = col(rel, selected ? (C.cyan + C.bold) : C.gray);
3408
+ const pad = Math.max(2, 40 - visualCol(rel));
3409
+ addHit(lines.length, 1, insideW, { kind: 'mentionItem', index: globalIndex });
3410
+ lines.push(col('│' + fitAnsi(mark + label + ' '.repeat(pad), insideW) + '│', C.border));
3140
3411
  });
3141
3412
  lines.push(col('│' + fitAnsi(col(`(${sel}/${totalMatches}) Enter or Tab to insert · Esc to dismiss`, C.gray), insideW) + '│', C.border));
3142
3413
  }
@@ -3182,6 +3453,28 @@ export function composeFrame(state, cols, rows) {
3182
3453
  if (agentTasks > 0) parts.push(col(`[${agentTasks} agent${agentTasks === 1 ? '' : 's'} running]`, C.spring));
3183
3454
  }
3184
3455
  if (state.slCwd !== false && cwd) parts.push(col(cwd, C.gray));
3456
+ // GIT badge: branch, then the working-tree diff totals. Placed AFTER the path so
3457
+ // it reads as "where I am → what branch → how much is uncommitted". Rendered
3458
+ // from state._gitInfo, which a 15s timer and turn-end refresh — the render path
3459
+ // itself never spawns git (see refreshGitInfo).
3460
+ if (state.slGit !== false) {
3461
+ const gi = state._gitInfo;
3462
+ if (gi && gi.branch) {
3463
+ // Branch name: pale yellow + bold. Branch and its diff are ONE part, joined
3464
+ // by a SINGLE space, so it reads as `main +1 -1` (tight). The outer
3465
+ // parts.join(' ') uses two spaces, so this still separates the git badge
3466
+ // from the path/model with two spaces while keeping branch↔diff at one.
3467
+ let badge = C.bold + col(gi.branch, C.branch);
3468
+ if (gi.insertions || gi.deletions) {
3469
+ // Two colours so the direction is readable at a glance: +N green, -N red.
3470
+ const bits = [];
3471
+ if (gi.insertions) bits.push(col(`+${gi.insertions}`, C.green));
3472
+ if (gi.deletions) bits.push(col(`-${gi.deletions}`, C.red));
3473
+ badge += ' ' + bits.join(' ');
3474
+ }
3475
+ parts.push(badge);
3476
+ }
3477
+ }
3185
3478
  const statusLeft = parts.join(' ');
3186
3479
  const statusRight = (state.slTips !== false && state.tip) ? col(state.tip, C.gray) : '';
3187
3480
  lines.push(justify(statusLeft, statusRight, w));
@@ -3405,6 +3698,10 @@ export function makeState({ cfg, session, opts }) {
3405
3698
  hoverHit: null,
3406
3699
  input: '',
3407
3700
  caret: 0,
3701
+ // 'prompt' (talk to the model) or 'bash' (run the line in the shell). Typing
3702
+ // `!` on an empty composer flips this; the `!` itself never enters `input`.
3703
+ // See composerInput() for why that matters.
3704
+ inputMode: 'prompt',
3408
3705
  composerSel: null,
3409
3706
  pastes: new Map(),
3410
3707
  pasteCounter: 0,
@@ -3445,6 +3742,13 @@ export function makeState({ cfg, session, opts }) {
3445
3742
  addDirs: [],
3446
3743
  tasks: {},
3447
3744
  slMode: true, slModel: true, slEffort: true, slCwd: true, slTasks: true, slTips: true,
3745
+ // Git badge in the status line: branch + working-tree diff totals. Refreshed
3746
+ // on a TTL, never per frame — each refresh costs a few git subprocesses and
3747
+ // composeFrame runs on every keystroke.
3748
+ slGit: true,
3749
+ _gitInfo: null, // { branch, insertions, deletions, changed, untracked }
3750
+ _gitAt: 0, // last refresh time (ms)
3751
+ _gitCwd: '', // directory the cached info belongs to
3448
3752
  picker: null,
3449
3753
  pickerQuery: '',
3450
3754
  pickerCategory: null, // active filter category (null = "All")
@@ -3491,21 +3795,48 @@ function stripTipPrefix(s) {
3491
3795
  }
3492
3796
 
3493
3797
  export function reconstructChat(s) {
3494
- const chat = [];
3495
- for (const m of (s && s.messages) || []) {
3798
+ // Shell commands the user ran with `!` are shown on resume at the position they
3799
+ // originally ran. Each entry's `anchor` is how many REAL conversation messages
3800
+ // (session.messages items) preceded it, so we inject it beside that message.
3801
+ // They live in session.shellHistory, never in session.messages — the model
3802
+ // never sees them.
3803
+ const shellByAnchor = new Map();
3804
+ const shells = (s && s.shellHistory) || [];
3805
+ for (const sh of shells) {
3806
+ const a = Number(sh.anchor != null ? sh.anchor : 0);
3807
+ if (!shellByAnchor.has(a)) shellByAnchor.set(a, []);
3808
+ shellByAnchor.get(a).push(sh);
3809
+ }
3810
+ // Emit the shell block for anchor `a` (command + its output) onto `out`.
3811
+ const emitShells = (out, a) => {
3812
+ for (const sh of shellByAnchor.get(a) || []) {
3813
+ out.push({ role: 'bash', text: String(sh.cmd || '') });
3814
+ if (sh.result) out.push({ role: 'tool_result', text: sh.result, failed: sh.ok === false });
3815
+ }
3816
+ };
3817
+
3818
+ const out = [];
3819
+ const msgs = (s && s.messages) || [];
3820
+ for (let mi = 0; mi < msgs.length; mi++) {
3821
+ const m = msgs[mi];
3496
3822
  const role = m.role;
3497
3823
  const text = typeof m.content === 'string' ? m.content : '';
3498
- if (role === 'user') chat.push({ role: 'user', text });
3824
+ // Inject shell commands that anchored BEFORE this message (in the slot after
3825
+ // the previous message).
3826
+ emitShells(out, mi);
3827
+ if (role === 'user') out.push({ role: 'user', text });
3499
3828
  else if (role === 'assistant') {
3500
- chat.push({ role: 'assistant', text });
3829
+ out.push({ role: 'assistant', text });
3501
3830
  for (const tc of (m.toolCalls || [])) {
3502
- chat.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
3831
+ out.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
3503
3832
  }
3504
3833
  } else if (role === 'tool') {
3505
- chat.push({ role: 'tool_result', text });
3834
+ out.push({ role: 'tool_result', text });
3506
3835
  }
3507
3836
  }
3508
- return chat;
3837
+ // Any shell commands anchored at/past the end land at the very tail.
3838
+ emitShells(out, msgs.length);
3839
+ return out;
3509
3840
  }
3510
3841
 
3511
3842
  export function pickerFiltered(state) {
@@ -3537,22 +3868,49 @@ export function refreshMenu(state) {
3537
3868
  return;
3538
3869
  }
3539
3870
  const prefix = val.slice(1).toLowerCase();
3871
+ // `/skill:` and `/skill:<partial>` list the installed skills as menu entries so
3872
+ // they are tab-completable exactly like a built-in command. The entry's `name`
3873
+ // is the full `skill:<name>` token, so accepting it types the whole thing.
3874
+ if (prefix.startsWith(SKILL_PREFIX)) {
3875
+ const partial = prefix.slice(SKILL_PREFIX.length);
3876
+ state.menuList = skillCompletions(partial).map((s) => ({
3877
+ name: s.insert,
3878
+ description: s.description || 'skill',
3879
+ _skill: true,
3880
+ }));
3881
+ state.menuSel = Math.min(state.menuSel, Math.max(0, state.menuList.length - 1));
3882
+ state.menuOpen = state.menuList.length > 0;
3883
+ ensureMenuVisible(state);
3884
+ return;
3885
+ }
3540
3886
  state.menuList = allCommands().filter((c) => c.name.startsWith(prefix));
3541
3887
  state.menuSel = Math.min(state.menuSel, Math.max(0, state.menuList.length - 1));
3542
3888
  state.menuOpen = state.menuList.length > 0;
3543
3889
  ensureMenuVisible(state);
3544
3890
  }
3545
3891
 
3892
+ // Scroll a fixed-height selection window so the selected row stays visible.
3893
+ // Shared by the `/` menu and the `@` candidate list: both render at most
3894
+ // MAX_MENU rows starting at `offset`, so a selection outside that window would
3895
+ // highlight NOTHING — which is exactly the "some entries never highlight" bug
3896
+ // the @ list had (it force-reset offset to 0 on every keystroke while the
3897
+ // selection could sit anywhere).
3898
+ export function windowOffset(sel, offset, count, max = MAX_MENU) {
3899
+ if (!count) return 0;
3900
+ const s = Math.max(0, Math.min(sel || 0, count - 1));
3901
+ let off = Math.max(0, Math.min(offset || 0, Math.max(0, count - max)));
3902
+ if (s < off) off = s;
3903
+ else if (s > off + max - 1) off = Math.max(0, s - max + 1);
3904
+ return off;
3905
+ }
3906
+
3546
3907
  function ensureMenuVisible(state) {
3547
3908
  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);
3909
+ state.menuOffset = windowOffset(state.menuSel, state.menuOffset, state.menuList.length);
3552
3910
  }
3553
3911
 
3554
3912
  // ---- command dispatch ----
3555
- async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, renderFrame) {
3913
+ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, renderFrame) {
3556
3914
  const { addChat, openPicker, openForm, notice, sendPrompt, quit, saveSession, openEditor } = h;
3557
3915
  const entry = findCommand(cmdRaw);
3558
3916
  const cmd = entry ? entry.name : String(cmdRaw || '').replace(/^\//, '');
@@ -3587,6 +3945,17 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3587
3945
 
3588
3946
  case 'plan': {
3589
3947
  const a = raw.toLowerCase();
3948
+ // `/plan saved` lists the plans persisted to <workspace>/.hncode/plans.
3949
+ if (a === 'saved' || a === 'list') {
3950
+ const items = listPlans(state.workspace || cfg.workspace);
3951
+ if (!items.length) { say('No saved plans yet. Approve a plan in Plan mode to save it.'); return; }
3952
+ h.openPanel('hncode — saved plans', [
3953
+ `${items.length} plan(s) in ${plansDir(state.workspace || cfg.workspace)}:`,
3954
+ '',
3955
+ ...items.map((p) => ` ${p.name} ${new Date(p.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')}`),
3956
+ ]);
3957
+ return;
3958
+ }
3590
3959
  if (a === 'clear') { state.plan = false; state.planPath = null; persist(); app('Plan cleared.'); return; }
3591
3960
  if (a === 'on') state.plan = true;
3592
3961
  else if (a === 'off') state.plan = false;
@@ -3961,7 +4330,6 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3961
4330
  const cwd = path.resolve(state.cwd || state.workspace || process.cwd());
3962
4331
  // Filter to only show sessions from the current workspace.
3963
4332
  const list = all.filter((s) => {
3964
- if (!Array.isArray(s.messages) || s.messages.length === 0) return false;
3965
4333
  const sessionCwd = s.workspace ? path.resolve(s.workspace) : null;
3966
4334
  return sessionCwd === cwd;
3967
4335
  });
@@ -3974,12 +4342,18 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3974
4342
  id: s.id,
3975
4343
  })),
3976
4344
  onPick: (it) => {
3977
- const hit = list.find((s) => s.id === it.id);
3978
- if (hit) {
4345
+ const meta = list.find((s) => s.id === it.id);
4346
+ if (meta) {
4347
+ // The list only carries METADATA (no messages — readSessionMeta strips
4348
+ // them). Assigning it straight onto `session` wiped the transcript, so
4349
+ // the resumed session appeared empty or stale. Load the FULL session
4350
+ // file (like --continue / --resume do), then merge its settings.
4351
+ const full = sess.loadSession(meta.id);
4352
+ if (!full) { app(`Could not load session ${meta.id}`); return true; }
3979
4353
  const keepModel = cfg.model;
3980
4354
  const keepInner = cfg.innerModel;
3981
4355
  const keepProvider = cfg.provider;
3982
- Object.assign(session, hit);
4356
+ Object.assign(session, full);
3983
4357
  cfg.model = keepModel;
3984
4358
  cfg.innerModel = keepInner;
3985
4359
  cfg.provider = keepProvider;
@@ -3996,7 +4370,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3996
4370
  state.ctxPercent = usagePercent(approx, state.ctxMax);
3997
4371
 
3998
4372
  const dur = session.lastTurnMs ? ` · last turn ${fmtDuration(session.lastTurnMs)}` : '';
3999
- app(`Resumed ${hit.id} (${hit.title || 'untitled'}) · ${(session.messages || []).length} messages.${dur}`);
4373
+ app(`Resumed ${meta.id} (${meta.title || 'untitled'}) · ${(session.messages || []).length} messages.${dur}`);
4000
4374
  }
4001
4375
  return true;
4002
4376
  },
@@ -4414,6 +4788,11 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4414
4788
  h.openPanel('hncode — commands', [
4415
4789
  ...allCommands().map((c) => ` /${c.name.padEnd(14)} ${(c.argumentHint || '').padEnd(26)} ${c.desc}`),
4416
4790
  '',
4791
+ 'Skills',
4792
+ ` /${SKILL_PREFIX}<name> run an installed skill (Tab completes the name)`,
4793
+ ' /import-skill <file.md> add or overwrite a skill from a Markdown file',
4794
+ ' /skills list installed skills',
4795
+ '',
4417
4796
  'Shortcuts',
4418
4797
  ' Enter send the message',
4419
4798
  ' Ctrl-J insert a newline',
@@ -4421,6 +4800,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4421
4800
  ' Ctrl+Shift+V paste the clipboard (multi-line pastes collapse)',
4422
4801
  ' ↑ / ↓ input history (empty composer) · scroll the chat',
4423
4802
  ' / open the command menu · Tab completes',
4803
+ ' ! on an empty prompt: switch to shell mode (Esc/Backspace to leave)',
4424
4804
  ' Esc cancel the menu / dialog · interrupt the turn',
4425
4805
  ' Ctrl+E open config.toml in editor',
4426
4806
  ' Ctrl-B move a running Bash command to the background',
@@ -4498,16 +4878,19 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4498
4878
  case 'version': app(`hncode v${VERSION}`); return;
4499
4879
 
4500
4880
  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.');
4881
+ // Show REAL connection state (from startup) alongside the configured
4882
+ // servers, plus each server's discovered tools. `/mcp tools` lists them.
4883
+ const cfgMcp = loadMcpConfig(state.workspace || cfg.workspace);
4884
+ const conns = getLiveConnections();
4885
+ const lines = describeServers(cfgMcp, conns);
4886
+ if (cfgMcp.errors.length) lines.push('', 'Config problems:', ...cfgMcp.errors.map((e) => ` ! ${e}`));
4887
+ const showTools = String(raw || '').toLowerCase() === 'tools';
4888
+ if (showTools) {
4889
+ const found = conns.filter((c) => c.ok).map((c) => ` ${c.name}: ${c.toolCount} tool(s)`);
4890
+ lines.push('', found.length ? 'Discovered tools:' : 'No servers connected — they connect at startup.');
4891
+ lines.push(...found);
4892
+ }
4893
+ h.openPanel('hncode — mcp', lines);
4511
4894
  return;
4512
4895
  }
4513
4896
  case 'mcp-config': {
@@ -4542,7 +4925,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
4542
4925
  return;
4543
4926
  }
4544
4927
 
4545
- case 'statusline':
4928
+ case 'statusline':
4546
4929
  openPicker({
4547
4930
  title: 'Status line items',
4548
4931
  items: [
@@ -4550,6 +4933,7 @@ case 'statusline':
4550
4933
  { label: 'model name', kind: 'toggle', isOn: state.slModel !== false },
4551
4934
  { label: 'thinking effort', kind: 'toggle', isOn: state.slEffort !== false },
4552
4935
  { label: 'current directory', kind: 'toggle', isOn: state.slCwd !== false },
4936
+ { label: 'git branch & diff', kind: 'toggle', isOn: state.slGit !== false },
4553
4937
  { label: 'background tasks', kind: 'toggle', isOn: state.slTasks !== false },
4554
4938
  { label: 'rotating tips', kind: 'toggle', isOn: state.slTips !== false },
4555
4939
  ],
@@ -4557,8 +4941,9 @@ case 'statusline':
4557
4941
  onPick: (it) => {
4558
4942
  it.isOn = !it.isOn;
4559
4943
  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];
4944
+ const key = { 'permission mode': 'slMode', 'model name': 'slModel', 'thinking effort': 'slEffort', 'current directory': 'slCwd', 'git branch & diff': 'slGit', 'background tasks': 'slTasks', 'rotating tips': 'slTips' }[it.label];
4561
4945
  state[key] = it.isOn;
4946
+ if (key === 'slGit') refreshGitInfo(state, { force: true });
4562
4947
  app(`Status line: ${it.label} → ${it.isOn ? 'shown' : 'hidden'}`);
4563
4948
  return false;
4564
4949
  },
@@ -4704,6 +5089,12 @@ case 'statusline':
4704
5089
  sess.saveSession(newSession);
4705
5090
  // Update state to use the new session.
4706
5091
  session = newSession;
5092
+ // The directory changed, so the status line (path + git badge) must follow.
5093
+ // It previously kept showing the OLD path because state.cwd was never
5094
+ // updated here.
5095
+ state.cwd = target;
5096
+ state.workspace = target;
5097
+ refreshGitInfo(state, { force: true });
4707
5098
  state.chat = reconstructChat(session);
4708
5099
  state.scroll = 0;
4709
5100
  state.rounds = session.rounds || 0;
@@ -4739,6 +5130,210 @@ case 'statusline':
4739
5130
  h.openPanel('hncode — plugins', lines);
4740
5131
  return;
4741
5132
  }
5133
+ case 'skills': {
5134
+ const parts = raw.split(/\s+/).filter(Boolean);
5135
+ const sub = (parts[0] || '').toLowerCase();
5136
+ if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
5137
+ const name = parts[1];
5138
+ if (!name) { appErr('Usage: /skills remove <name>'); return; }
5139
+ const ok = deleteSkill(name);
5140
+ app(ok ? `Skill removed: ${normalizeSkillName(name)}` : `No such skill: ${name}`);
5141
+ return;
5142
+ }
5143
+ if (sub === 'list' || sub === '') {
5144
+ const items = listSkills();
5145
+ if (!items.length) {
5146
+ say(`No skills installed. Add one with /import-skill <file.md> (dir: ${skillsDir()}).`);
5147
+ return;
5148
+ }
5149
+ h.openPanel('hncode — skills', [
5150
+ `Installed skills (${items.length}) — activate with /${SKILL_PREFIX}<name>:`,
5151
+ '',
5152
+ ...items.map((s) => ` /${SKILL_PREFIX}${s.name}${s.description ? ' — ' + s.description : ''}`),
5153
+ '',
5154
+ `Directory: ${skillsDir()}`,
5155
+ 'Remove one with /skills remove <name>.',
5156
+ ]);
5157
+ return;
5158
+ }
5159
+ appErr(`Usage: /skills [list] | remove <name>`);
5160
+ return;
5161
+ }
5162
+ case 'import-skill': {
5163
+ // Usage: /import-skill <file.md> [name]
5164
+ const parts = raw.split(/\s+/).filter(Boolean);
5165
+ const file = parts[0];
5166
+ const nameArg = parts[1];
5167
+ if (!file) { appErr('Usage: /import-skill <file.md> [name]'); return; }
5168
+ const res = importSkill(file, nameArg);
5169
+ if (!res.ok) { appErr(res.error); return; }
5170
+ app(`${res.replaced ? 'Skill overwritten' : 'Skill imported'}: ${res.name} → ${res.path}`);
5171
+ say(`Activate it with /${SKILL_PREFIX}${res.name}`);
5172
+ return;
5173
+ }
5174
+ case 'hooks': {
5175
+ const workspace = state.workspace || cfg.workspace || process.cwd();
5176
+ const parts = raw.split(/\s+/).filter(Boolean);
5177
+ const sub = (parts[0] || '').toLowerCase();
5178
+ if (sub === 'test') {
5179
+ const event = parts[1];
5180
+ if (!event || !HOOK_EVENTS.includes(event)) {
5181
+ appErr(`Usage: /hooks test <event> (events: ${HOOK_EVENTS.join(', ')})`);
5182
+ return;
5183
+ }
5184
+ const { hooks } = loadHooks(workspace);
5185
+ const res = await runShellHooks({ hooks }, event, { toolName: 'Bash', toolArgs: { command: 'echo hi' }, prompt: 'test' }, workspace);
5186
+ if (!(hooks[event] || []).length) { app(`No ${event} hooks configured.`); return; }
5187
+ const lines = res.results.map((r) => {
5188
+ const head = r.ok ? `✔ ${r.entry.command}` : `✖ ${r.entry.command} (${r.message})`;
5189
+ const body = (r.stderr || r.stdout || '').trim();
5190
+ return body ? `${head}\n${body}` : head;
5191
+ });
5192
+ h.openPanel(`hncode — hooks: ${event} (test)`, [
5193
+ `Ran ${res.results.length} hook(s)${res.blocked ? ' — the tool WOULD be blocked' : ''}.`,
5194
+ '',
5195
+ ...lines,
5196
+ ]);
5197
+ return;
5198
+ }
5199
+ const { hooks, errors } = loadHooks(workspace);
5200
+ const lines = describeHooks({ hooks });
5201
+ if (errors.length) lines.push('', 'Config problems:', ...errors.map((e) => ` ! ${e}`));
5202
+ h.openPanel('hncode — hooks', lines);
5203
+ return;
5204
+ }
5205
+ case 'git': {
5206
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5207
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5208
+ refreshGitInfo(state, { force: true }); // keep the badge in sync with this view
5209
+ const branch = gitmod.currentBranch(cwd);
5210
+ const s = gitmod.status(cwd);
5211
+ const info = gitmod.parseRemote(gitmod.remoteUrl(cwd));
5212
+ const lines = [
5213
+ `branch: ${branch || '(unknown)'}`,
5214
+ `head: ${gitmod.headSha(cwd)}`,
5215
+ info ? `remote: ${info.owner}/${info.repo} (${info.host})` : 'remote: (none)',
5216
+ '',
5217
+ `changes (${s.ok ? s.entries.length : '?'}):`,
5218
+ ...gitmod.statusLines(cwd).map((l) => ' ' + l),
5219
+ ];
5220
+ h.openPanel('hncode — git', lines);
5221
+ return;
5222
+ }
5223
+ case 'commit': {
5224
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5225
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5226
+ // `/commit <message>` commits what is already staged, staging everything
5227
+ // when the index is empty (the common "commit my changes" case).
5228
+ // `/commit` with NO message hands the job to the model, which can read the
5229
+ // diff and draft a message — that is the point of having an agent.
5230
+ const message = String(raw || '').trim();
5231
+ if (!message) {
5232
+ const s = gitmod.status(cwd);
5233
+ if (s.ok && !s.entries.length) { app('Nothing to commit — working tree is clean.'); return; }
5234
+ const branch = gitmod.currentBranch(cwd);
5235
+ const pending = gitmod.statusLines(cwd).slice(0, 40).join('\n');
5236
+ await submit(
5237
+ `Create a git commit for the current changes.\n\n`
5238
+ + `Repository state (branch: ${branch}):\n${pending}\n\n`
5239
+ + `Steps: run \`git diff\` (and \`git status\`) to review the changes, stage the relevant files, then commit with a concise message `
5240
+ + `that explains the intent. Match the repository's existing commit-message style. Do not push. `
5241
+ + `Finally, report the commit hash and a one-line summary of what was committed.`,
5242
+ );
5243
+ return;
5244
+ }
5245
+ if (!gitmod.hasStagedChanges(cwd)) {
5246
+ const st = gitmod.stage([], cwd);
5247
+ if (!st.ok) { appErr(`git add failed: ${st.error}`); return; }
5248
+ }
5249
+ const res = gitmod.commit(message, cwd);
5250
+ if (!res.ok) { appErr(`Commit failed: ${res.error}`); return; }
5251
+ app(`Committed ${res.sha}: ${message.split('\n')[0]}`);
5252
+ const files = res.stdout.split('\n').slice(0, 8).join('\n');
5253
+ say(`Committed ${res.sha}\n${files}`);
5254
+ return;
5255
+ }
5256
+ case 'branch': {
5257
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5258
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5259
+ const parts = raw.split(/\s+/).filter(Boolean);
5260
+ const sub = (parts[0] || '').toLowerCase();
5261
+ if (!sub || sub === 'list') {
5262
+ const cur = gitmod.currentBranch(cwd);
5263
+ const branches = gitmod.listBranches(cwd);
5264
+ if (!branches.length) { app(`No branches yet (current: ${cur}).`); return; }
5265
+ say(`Branches (current: ${cur}):\n` + branches.map((b) => ` ${b === cur ? '* ' : ' '}${b}`).join('\n'));
5266
+ return;
5267
+ }
5268
+ if (sub === '-c' || sub === 'create' || sub === 'new') {
5269
+ const name = parts[1];
5270
+ if (!name) { appErr('Usage: /branch -c <name> [from]'); return; }
5271
+ const r = gitmod.createBranch(name, cwd, parts[2]);
5272
+ if (!r.ok) { appErr(`Could not create branch: ${r.error}`); return; }
5273
+ app(`Switched to new branch: ${name}`);
5274
+ return;
5275
+ }
5276
+ const r = gitmod.switchBranch(parts[0], cwd);
5277
+ if (!r.ok) { appErr(`Could not switch branch: ${r.error}`); return; }
5278
+ app(`Switched to branch: ${parts[0]}`);
5279
+ return;
5280
+ }
5281
+ case 'pr': {
5282
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5283
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5284
+ const base = String(raw || '').trim() || 'main';
5285
+ const url = gitmod.prUrl(cwd, base);
5286
+ if (!url) { appErr('No git remote named "origin" — cannot build a PR URL.'); return; }
5287
+ const branch = gitmod.currentBranch(cwd);
5288
+ say(`Open a pull request for ${branch} → ${base}:\n ${url}`);
5289
+ app('PR link copied to the transcript (Ctrl+Shift+C to copy).');
5290
+ return;
5291
+ }
5292
+ case 'cache': {
5293
+ // Prompt caching: explicit cache breakpoints for the stable prefix (tools +
5294
+ // system + conversation head). Saves input tokens on every turn after the
5295
+ // first. Anthropic gets cache_control markers; OpenAI-compatible providers
5296
+ // get a stable prompt_cache_key.
5297
+ const a = String(raw || '').toLowerCase();
5298
+ const want = a === 'on' ? true : a === 'off' ? false : !(cfg.promptCache !== false);
5299
+ cfg.promptCache = want;
5300
+ try { setConfigString('prompt_cache', want ? 'true' : 'false'); } catch { /* best-effort persist */ }
5301
+ app(`Prompt caching: ${want ? 'ON' : 'OFF'}`);
5302
+ say(want
5303
+ ? 'The stable prefix (tools + system + conversation head) is marked cacheable.\n'
5304
+ + 'Providers bill cached input at a fraction of the normal rate. Effective\n'
5305
+ + 'for: anthropic (cache_control), openai-compatible (prompt_cache_key).'
5306
+ : 'Prompt caching disabled — every request sends the full prompt as fresh input.');
5307
+ return;
5308
+ }
5309
+ case 'worktree': {
5310
+ const cwd = state.cwd || cfg.workspace || process.cwd();
5311
+ if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
5312
+ const parts = raw.split(/\s+/).filter(Boolean);
5313
+ const sub = (parts[0] || '').toLowerCase();
5314
+ if (!sub || sub === 'list') {
5315
+ const list = gitmod.listWorktrees(cwd);
5316
+ say(`Worktrees (${list.length}):\n` + list.map((w) => ` ${w.path} ${w.branch ? '[' + w.branch + ']' : w.detached ? '(detached)' : ''}`).join('\n'));
5317
+ return;
5318
+ }
5319
+ if (sub === 'add') {
5320
+ const dir = parts[1];
5321
+ if (!dir) { appErr('Usage: /worktree add <dir> [branch]'); return; }
5322
+ const r = gitmod.addWorktree(dir, parts[2], cwd);
5323
+ if (!r.ok) { appErr(`Could not add worktree: ${r.error}`); return; }
5324
+ app(r.reused ? `Worktree already exists: ${r.path}` : `Worktree created: ${r.path}${r.branch ? ` [${r.branch}]` : ''}`);
5325
+ return;
5326
+ }
5327
+ if (sub === 'remove' || sub === 'rm') {
5328
+ const dir = parts[1];
5329
+ if (!dir) { appErr('Usage: /worktree remove <dir>'); return; }
5330
+ const r = gitmod.removeWorktree(dir, cwd, { force: parts.includes('--force') });
5331
+ app(r.ok ? `Worktree removed: ${dir}` : `Could not remove worktree: ${r.error}`);
5332
+ return;
5333
+ }
5334
+ appErr('Usage: /worktree [list] | add <dir> [branch] | remove <dir>');
5335
+ return;
5336
+ }
4742
5337
  case 'logout': {
4743
5338
  const pr = (cfg.raw && cfg.raw.providers) || {};
4744
5339
  const names = Object.keys(pr);
@@ -4774,7 +5369,23 @@ case 'statusline':
4774
5369
  }
4775
5370
  case 'exit': quit(); return;
4776
5371
 
4777
- default:
5372
+ default: {
5373
+ // A skill activation: /skill:<name> [extra args]. The skill's body is sent
5374
+ // as the turn's prompt, so it behaves like a saved prompt fragment the user
5375
+ // can recall by name (tab-completed in the composer).
5376
+ const rawName = String(cmdRaw || '').replace(/^\/+/, '');
5377
+ if (rawName.toLowerCase().startsWith(SKILL_PREFIX)) {
5378
+ const name = normalizeSkillName(rawName);
5379
+ const sk = readSkill(name);
5380
+ if (!sk || !sk.body) {
5381
+ appErr(`No such skill: ${name}. Use /skills to list them, or /import-skill to add one.`);
5382
+ return;
5383
+ }
5384
+ const extra = String(raw || '').trim();
5385
+ const prompt = skillPrompt(sk) + (extra ? `\n\n${extra}` : '');
5386
+ await submit(prompt);
5387
+ return;
5388
+ }
4778
5389
  // Check if this is a plugin-registered command.
4779
5390
  if (isPluginCommand(cmd)) {
4780
5391
  const pc = findCommand(cmd);
@@ -4787,6 +5398,7 @@ case 'statusline':
4787
5398
  // Unknown command: show error message
4788
5399
  appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
4789
5400
  return;
5401
+ }
4790
5402
  }
4791
5403
  }
4792
5404
 
@@ -5310,8 +5922,10 @@ export async function startTUI(opts) {
5310
5922
  });
5311
5923
  state.mentionList = ranked.slice(0, 200).map((r) => r.f);
5312
5924
  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;
5925
+ // Keep the window on the selected row. This used to reset the offset to 0,
5926
+ // which left a selection beyond MAX_MENU rows invisible — the renderer only
5927
+ // walks [offset, offset+MAX_MENU), so nothing at all appeared highlighted.
5928
+ state.mentionOffset = windowOffset(state.mentionSel, state.mentionOffset, state.mentionList.length);
5315
5929
  state.mentionOpen = state.mentionList.length > 0;
5316
5930
  }
5317
5931
 
@@ -5537,9 +6151,23 @@ export async function startTUI(opts) {
5537
6151
  // and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
5538
6152
  warmClipboard();
5539
6153
  setTip(state);
6154
+ // Populate the git badge before the first paint so the status line is correct
6155
+ // immediately (from then on the 15s timer / turn-end keep it current).
6156
+ refreshGitInfo(state, { force: true });
5540
6157
  renderFrame();
5541
6158
 
5542
6159
  const tipTimer = setInterval(() => { setTip(state); renderFrame(); }, TIP_INTERVAL);
6160
+ // Git badge: a fixed 15s cadence. Deliberately NOT tied to the frame loop (that
6161
+ // spawned git per keystroke) and NOT run mid-turn (the agent is still writing;
6162
+ // the turn-end refresh picks up its edits). Repaints only when something
6163
+ // actually changed, so an idle session stays quiet.
6164
+ const gitTimer = setInterval(() => {
6165
+ if (state.running) return;
6166
+ const before = state._gitInfo && `${state._gitInfo.branch}|${state._gitInfo.insertions}|${state._gitInfo.deletions}`;
6167
+ refreshGitInfo(state, { force: true });
6168
+ const after = state._gitInfo && `${state._gitInfo.branch}|${state._gitInfo.insertions}|${state._gitInfo.deletions}`;
6169
+ if (before !== after) renderFrame();
6170
+ }, GIT_REFRESH_MS);
5543
6171
  const spinTimer = setInterval(() => {
5544
6172
  if (!state.running) return;
5545
6173
  // Monotonic tick counter — do NOT wrap it at SPINNER.length, or the
@@ -5671,6 +6299,14 @@ export async function startTUI(opts) {
5671
6299
  function dispatchHit(hb) {
5672
6300
  if (!hb) return false;
5673
6301
  switch (hb.kind) {
6302
+ case 'mentionItem': {
6303
+ // Mirror menuItem: clicking an @candidate selects AND accepts it. The
6304
+ // case was simply missing, so a click fell through to `default: false`
6305
+ // and did nothing at all.
6306
+ state.mentionSel = hb.index;
6307
+ acceptMention();
6308
+ return true;
6309
+ }
5674
6310
  case 'menuItem': {
5675
6311
  state.menuSel = hb.index;
5676
6312
  const cmd = state.menuList[hb.index];
@@ -5981,16 +6617,25 @@ export async function startTUI(opts) {
5981
6617
  function insertComposerPaste(raw) {
5982
6618
  const text = String(raw == null ? '' : raw).replace(/\r\n?/g, '\n');
5983
6619
  if (!text) return false;
5984
- const lineCount = text.split('\n').length;
5985
- let insert = text;
6620
+ // A paste starting with `!` into an EMPTY prompt means "run this in the
6621
+ // shell" — enter shell mode and drop the marker, exactly like typing it. Same
6622
+ // rule as kimi-code's editor, so a copied `!git status` behaves identically.
6623
+ let body = text;
6624
+ if (state.inputMode !== 'bash' && (state.input || '').length === 0 && body.startsWith('!')) {
6625
+ state.inputMode = 'bash';
6626
+ body = body.replace(/^!+/, '');
6627
+ }
6628
+ const lineCount = body.split('\n').length;
6629
+ let insert = body;
5986
6630
  if (lineCount > 1) {
5987
6631
  const id = ++state.pasteCounter;
5988
- state.pastes.set(id, { text, lines: lineCount });
6632
+ state.pastes.set(id, { text: body, lines: lineCount });
5989
6633
  insert = pasteMarker(id, lineCount);
5990
6634
  }
5991
6635
  state.input = (state.input || '').slice(0, state.caret) + insert + (state.input || '').slice(state.caret);
5992
6636
  state.caret += insert.length;
5993
- refreshMenu(state);
6637
+ // Shell mode offers no `/` commands or `@` files.
6638
+ if (state.inputMode !== 'bash') refreshMenu(state);
5994
6639
  return true;
5995
6640
  }
5996
6641
  // Async so the notice can be painted BEFORE the clipboard read: a cold
@@ -6151,25 +6796,20 @@ export async function startTUI(opts) {
6151
6796
  // keep drafting while deciding.
6152
6797
  if (t.key === 'c-c') return;
6153
6798
  }
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.
6799
+ // Plan review: the model produced a <plan> and Plan mode is waiting.
6800
+ // Enter/Space = approve & execute, e = edit first, Esc = keep planning.
6157
6801
  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') {
6802
+ const settle = (outcome) => {
6166
6803
  const resolve = state.planPending.resolve;
6167
6804
  state.planPending = null;
6168
- resolve(false);
6805
+ resolve(outcome);
6169
6806
  renderFrame();
6170
- return;
6171
- }
6807
+ };
6808
+ if (t.key === 'enter' || t.key === ' ') { settle('approve'); return; }
6809
+ if (t.key === 'escape') { settle('keep'); return; }
6810
+ if (t.ch === 'e' || t.ch === 'E') { settle('edit'); return; }
6172
6811
  if (t.key === 'c-c') return; // do not exit while a plan is under review
6812
+ return; // swallow other keys while reviewing
6173
6813
  }
6174
6814
  if (t.key === 'c-c') {
6175
6815
  // An open overlay consumes Ctrl+C: close it instead of interrupting the
@@ -6407,7 +7047,14 @@ export async function startTUI(opts) {
6407
7047
  // transcript. Without this guard the running block below stole those keys.
6408
7048
  // The modal editor (e.g. from /set-system-prompt) is included so that Esc and
6409
7049
  // 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);
7050
+ //
7051
+ // `mentionOpen` MUST be in this list: it is the inline `@file` candidate list,
7052
+ // which is a keyboard-owning overlay just like the `/` menu. Leaving it out
7053
+ // meant that while the agent was streaming, ↑/↓ scrolled the transcript
7054
+ // (instead of moving the selection) and Esc interrupted the turn (instead of
7055
+ // dismissing the list) — the keys never reached the mention block below.
7056
+ const overlayOpen = !!(state.picker || state.form || state.panel || state.menuOpen
7057
+ || state.mentionOpen || state.editor);
6411
7058
  if (state.running && !overlayOpen) {
6412
7059
  if (t.key === 'escape') {
6413
7060
  if (state.agent) state.agent.interrupt();
@@ -6422,13 +7069,32 @@ export async function startTUI(opts) {
6422
7069
  renderFrame(); return;
6423
7070
  }
6424
7071
  if (t.key === 'up' || t.key === 'down') {
7072
+ // ↑/↓ only scroll when the COMPOSER has no use for the key. While the
7073
+ // agent streams you are usually typing the next instruction, so a
7074
+ // multi-line draft must still move its caret — swallowing the key for
7075
+ // scrolling unconditionally is what made ↑/↓ "always scroll". When the
7076
+ // caret is already on the first/last row the key falls through to the
7077
+ // scroll below, so both behaviours are available.
7078
+ const insideW = Math.max(0, dims().cols - 2);
7079
+ const layout = composerLayout(state, insideW - 3);
7080
+ const dir = t.key === 'down' ? 1 : -1;
7081
+ const targetRow = layout.caretRow + dir;
7082
+ if (targetRow >= 0 && targetRow < layout.rows.length) {
7083
+ const starts = rowStartOffsets(state.input || '', layout.rows.length, insideW);
7084
+ const colInRow = Math.max(0, layout.caretCol - 1);
7085
+ const targetStart = starts[targetRow] != null ? starts[targetRow] : 0;
7086
+ state.caret = Math.min((state.input || '').length, targetStart + colInRow);
7087
+ state.composerSel = null;
7088
+ renderFrame(); return;
7089
+ }
6425
7090
  scrollChat(state, t.key === 'up' ? 3 : -3);
6426
7091
  renderFrame(); return;
6427
7092
  }
6428
7093
  }
6429
7094
  else {
6430
7095
  if (t.key === 'up' || t.key === 'down') {
6431
- const overlay = state.picker || state.form || state.panel || state.menuOpen || state.editor;
7096
+ const overlay = state.picker || state.form || state.panel || state.menuOpen
7097
+ || state.mentionOpen || state.editor;
6432
7098
  const cur = state.input || '';
6433
7099
  if (!overlay && cur === '' && state.history.length) {
6434
7100
  if (state.historyIdx === -1) state.historyIdx = state.history.length;
@@ -6698,6 +7364,13 @@ export async function startTUI(opts) {
6698
7364
  }
6699
7365
 
6700
7366
  if (t.key === 'escape') {
7367
+ // In shell mode a bare Esc on an empty buffer exits the mode (nothing to
7368
+ // clear); with text present it falls through and clears as usual.
7369
+ if (state.inputMode === 'bash' && state.input.length === 0) {
7370
+ state.inputMode = 'prompt';
7371
+ renderFrame();
7372
+ return;
7373
+ }
6701
7374
  state.menuOpen = false; state.menuList = []; state.menuSel = 0;
6702
7375
  state.menuOffset = 0;
6703
7376
  state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
@@ -6705,6 +7378,7 @@ export async function startTUI(opts) {
6705
7378
  state.input = ''; state.caret = 0;
6706
7379
  state.pastes.clear(); state.pasteCounter = 0;
6707
7380
  state.composerSel = null;
7381
+ state.inputMode = 'prompt';
6708
7382
  refreshMenu(state);
6709
7383
  renderFrame();
6710
7384
  return;
@@ -6726,8 +7400,7 @@ export async function startTUI(opts) {
6726
7400
  if (n) {
6727
7401
  state.mentionSel = (state.mentionSel + (t.key === 'down' ? 1 : n - 1)) % n;
6728
7402
  // 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;
7403
+ state.mentionOffset = windowOffset(state.mentionSel, state.mentionOffset, n);
6731
7404
  renderFrame();
6732
7405
  }
6733
7406
  return;
@@ -6798,6 +7471,16 @@ export async function startTUI(opts) {
6798
7471
  renderFrame();
6799
7472
  return;
6800
7473
  }
7474
+ // The @ candidate list scrolls its own selection, exactly like /menu —
7475
+ // otherwise a wheel notch scrolled the transcript behind an open list.
7476
+ if (state.mentionOpen && state.mentionList.length) {
7477
+ const n = state.mentionList.length;
7478
+ const cur = Math.min(state.mentionSel, n - 1);
7479
+ state.mentionSel = (cur + (t.key === 'wheeldown' ? 1 : n - 1)) % n;
7480
+ state.mentionOffset = windowOffset(state.mentionSel, state.mentionOffset, n);
7481
+ renderFrame();
7482
+ return;
7483
+ }
6801
7484
  scrollChat(state, d);
6802
7485
  renderFrame(); return;
6803
7486
  }
@@ -6914,6 +7597,14 @@ export async function startTUI(opts) {
6914
7597
  return;
6915
7598
  }
6916
7599
  if (t.key === 'backspace') {
7600
+ // In shell mode with an empty buffer there is nothing to delete, so the
7601
+ // Backspace reads as "remove the `!`" and drops back to prompt mode —
7602
+ // exactly the kimi-code behaviour.
7603
+ if (state.inputMode === 'bash' && state.input.length === 0 && !(state.composerSel && state.composerSel.anchor !== state.composerSel.head)) {
7604
+ state.inputMode = 'prompt';
7605
+ renderFrame();
7606
+ return;
7607
+ }
6917
7608
  const sel = state.composerSel;
6918
7609
  if (sel && sel.anchor !== sel.head) {
6919
7610
  const a = Math.min(sel.anchor, sel.head);
@@ -6962,14 +7653,27 @@ export async function startTUI(opts) {
6962
7653
  renderFrame(); return;
6963
7654
  }
6964
7655
  if (t.ch) {
7656
+ // `!` on an EMPTY prompt toggles shell mode instead of being inserted — the
7657
+ // `!` becomes the prefix, so the buffer holds only the command. Mirrors
7658
+ // kimi-code's editor: the marker never enters the text, so the caret never
7659
+ // steps over it and submit never strips it.
7660
+ if (t.ch === '!' && state.inputMode !== 'bash' && state.input.length === 0) {
7661
+ state.inputMode = 'bash';
7662
+ state.composerSel = null;
7663
+ renderFrame();
7664
+ return;
7665
+ }
6965
7666
  state.input = state.input.slice(0, state.caret) + t.ch + state.input.slice(state.caret);
6966
7667
  state.caret++;
6967
7668
  state.composerSel = null;
6968
7669
  // Any printable character can extend or start an `@mention`, so refresh the
6969
7670
  // 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);
7671
+ // narrows it — the same live-feedback the `/` menu gives. In shell mode
7672
+ // there are no @mentions or slash-commands to offer, so skip both.
7673
+ if (state.inputMode !== 'bash') {
7674
+ refreshMention();
7675
+ if (!state.mentionOpen) refreshMenu(state);
7676
+ }
6973
7677
  renderFrame();
6974
7678
  }
6975
7679
  }
@@ -7037,6 +7741,16 @@ export async function startTUI(opts) {
7037
7741
  }
7038
7742
 
7039
7743
  if (state.history[state.history.length - 1] !== text) state.history.push(text);
7744
+ // Shell passthrough. The MODE is authoritative (the `!` never entered the
7745
+ // buffer); a leading `!` in the text is still honoured so a pasted line like
7746
+ // `!git status` behaves the same as typing it.
7747
+ if (state.inputMode === 'bash' || text.startsWith('!')) {
7748
+ const cmd = text.replace(/^!+/, '').trim();
7749
+ state.inputMode = 'prompt'; // one command per activation, like kimi
7750
+ if (!cmd) { renderFrame(); return; }
7751
+ await runShellCommand(cmd);
7752
+ return;
7753
+ }
7040
7754
  // Commands are handled immediately, never queued: queuing them delayed a
7041
7755
  // /plan or /model until the running turn finished, which is not what
7042
7756
  // typing a command means.
@@ -7061,6 +7775,80 @@ export async function startTUI(opts) {
7061
7775
  await runAgent(text);
7062
7776
  }
7063
7777
 
7778
+ // `!command` — run a shell command directly, without the model.
7779
+ //
7780
+ // The composer path is deliberately bypassed: the command runs through the same
7781
+ // Bash tool the agent uses (so it gets the same timeout, cwd, output
7782
+ // sanitising and truncation), but its result is rendered as a `↳` receipt and
7783
+ // NOT sent to the model. That is the point — a quick `git add -A && git push`
7784
+ // should be instant and predictable, not a round-trip the agent might reword.
7785
+ //
7786
+ // The command is echoed into the transcript so the scrollback shows what was
7787
+ // run, and it is kept out of `session.messages` so it does not pollute the
7788
+ // conversation the model sees.
7789
+ async function runShellCommand(cmd) {
7790
+ const started = Date.now();
7791
+ // Echo the command as a dedicated `bash` role so the transcript renders it
7792
+ // with SHELL MODE styling (violet frame + `!` glyph). It is recorded in
7793
+ // session.shellHistory (persisted, shown on resume) but NEVER in
7794
+ // session.messages, so the model never sees the command.
7795
+ addChat({ role: 'bash', text: cmd });
7796
+ // Record the command for the NEXT session's display. It lives in its own
7797
+ // field, not session.messages, so it survives a restart for the user to see
7798
+ // but is never sent to the model. `anchor` remembers WHERE in the
7799
+ // conversation this shell command ran (how many real messages came before
7800
+ // it), so a resume can put it back in that spot instead of appending it at
7801
+ // the very end. The result/exit is patched once it runs.
7802
+ if (session) {
7803
+ session.shellHistory = session.shellHistory || [];
7804
+ session.shellHistory.push({ cmd, ts: Date.now(), ok: null, anchor: (session.messages || []).length });
7805
+ }
7806
+ state.scroll = 0;
7807
+ state.running = true;
7808
+ state.startAnim = { start: Date.now() };
7809
+ renderFrame();
7810
+
7811
+ let out = '';
7812
+ let failed = false;
7813
+ try {
7814
+ const bash = getTool('Bash');
7815
+ if (!bash) {
7816
+ out = 'Error: the Bash tool is unavailable.';
7817
+ failed = true;
7818
+ } else {
7819
+ const result = await bash.execute(
7820
+ { command: cmd, description: 'shell passthrough' },
7821
+ { ...cfg, cwd: state.cwd || state.workspace, workspace: state.workspace || state.cwd, allowExternal: true },
7822
+ );
7823
+ out = String(result == null ? '' : result);
7824
+ failed = isFailureResult(out, 'Bash');
7825
+ }
7826
+ } catch (e) {
7827
+ out = `Error running command: ${e.message}`;
7828
+ failed = true;
7829
+ } finally {
7830
+ state.running = false;
7831
+ }
7832
+
7833
+ addChat({ role: 'tool_result', text: out, failed });
7834
+ const ms = Date.now() - started;
7835
+ addChat({ role: 'system', text: `[${cmd.split('\n')[0]} — ${failed ? 'failed' : 'done'} in ${fmtDuration(ms)}]` });
7836
+ // Close the tail of the shellHistory entry pushed at the start: mark it
7837
+ // done/failed with a short result preview, then persist the session so the
7838
+ // command (and its outcome) survives a restart.
7839
+ if (session && Array.isArray(session.shellHistory) && session.shellHistory.length) {
7840
+ const last = session.shellHistory[session.shellHistory.length - 1];
7841
+ last.ok = !failed;
7842
+ last.doneAt = Date.now();
7843
+ last.result = String(out || '').slice(0, 500); // keep a peek, bound it
7844
+ try { sess.saveSession(session); } catch { /* best-effort */ }
7845
+ }
7846
+ // A shell command can change the working tree, so refresh the git badge now
7847
+ // rather than waiting for the next TTL tick.
7848
+ refreshGitInfo(state, { force: true });
7849
+ renderFrame();
7850
+ }
7851
+
7064
7852
  // `asSystem` injects the text as a SYSTEM message instead of a user message and
7065
7853
  // skips the user bubble. Used by the approved-plan handoff: the plan is not
7066
7854
  // something the user typed, so it must not appear (or persist) as their message.
@@ -7120,6 +7908,17 @@ export async function startTUI(opts) {
7120
7908
  if (agents) {
7121
7909
  sysText += '\n\n' + agents;
7122
7910
  }
7911
+ // GIT CONTEXT: the current branch and how many files are uncommitted. Cheap
7912
+ // (two git calls) and it stops the model guessing whether the tree is dirty
7913
+ // or which branch it is on. Skipped silently outside a repository.
7914
+ {
7915
+ const gitCwd = state.cwd || state.workspace || cfg.workspace;
7916
+ let gitsum = '';
7917
+ try { gitsum = gitmod.summary(gitCwd); } catch { gitsum = ''; }
7918
+ if (gitsum) {
7919
+ sysText += '\n\nGIT WORKING TREE (this repository, as of now):\n' + gitsum;
7920
+ }
7921
+ }
7123
7922
  let messages = [{ role: 'system', content: sysText }];
7124
7923
  if (session.messages && session.messages.length) {
7125
7924
  for (const m of session.messages) {
@@ -7267,7 +8066,12 @@ export async function startTUI(opts) {
7267
8066
  });
7268
8067
  const agent = new Agent({
7269
8068
  // maxSteps omitted: the agent loop is uncapped (see agent.js).
7270
- cfg, messages, onApproval,
8069
+ // sessionId makes the prompt-cache key stable per session (see cache.js).
8070
+ cfg: { ...cfg, sessionId: (session && session.id) || cfg.sessionId },
8071
+ messages, onApproval,
8072
+ // Shell hooks are re-read per turn so an edit to hooks.json (or a /hooks
8073
+ // change) takes effect on the next message without restarting.
8074
+ hookConfig: loadHooks(cfg.workspace).hooks,
7271
8075
  onEvent: (e) => {
7272
8076
  // Everything the MODEL streams counts toward the tok/s meter: reasoning
7273
8077
  // chunks carry `text` just like answer chunks do.
@@ -7349,15 +8153,23 @@ export async function startTUI(opts) {
7349
8153
  if (!dup) {
7350
8154
  state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '', startAt: Date.now() });
7351
8155
  }
7352
- } else if (e.type === 'tool_args') {
7353
8156
  } else if (e.type === 'tool_args') {
7354
8157
  const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id);
7355
8158
  if (entry) {
7356
- entry._argsRaw = (entry._argsRaw || '') + e.chunk;
8159
+ // Incremental scan: each chunk is parsed ONCE and the parse state
8160
+ // carries forward. Re-parsing the whole accumulated blob per chunk was
8161
+ // O(n²) and froze the UI on a large Write (see createArgStream).
8162
+ if (!entry._argStream) entry._argStream = createArgStream();
8163
+ feedArgStream(entry._argStream, e.chunk);
7357
8164
  // Track the key argument as it streams so "Using Bash (cmd)" shows
7358
8165
  // 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');
8166
+ entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
8167
+ if (entry.toolName === 'Write') {
8168
+ entry.streamContent = argStreamValue(entry._argStream, 'content');
8169
+ // Incremental line count so the preview's "… N more lines" note is
8170
+ // exact without re-scanning the whole content every frame.
8171
+ entry._streamLineCount = argStreamLineCount(entry._argStream, 'content');
8172
+ }
7361
8173
  }
7362
8174
  renderSoon(); return;
7363
8175
  } else if (e.type === 'tool_output') {
@@ -7521,20 +8333,60 @@ export async function startTUI(opts) {
7521
8333
  else addChat({ role: 'plan', text: planText });
7522
8334
  if (lastAssistant) {
7523
8335
  lastAssistant.text = String(lastAssistant.text).replace(/<\/?plan>/gi, '').trim();
8336
+ // This is a REPLACEMENT, not an append, so the length-based render-cache
8337
+ // fingerprint could coincide with the previous value and leave a stale
8338
+ // frame. Drop the cache explicitly.
8339
+ lastAssistant._cache = null;
7524
8340
  if (!lastAssistant.text.trim()) {
7525
8341
  const i = state.chat.indexOf(lastAssistant);
7526
8342
  if (i >= 0) state.chat.splice(i, 1);
7527
8343
  }
7528
8344
  }
7529
8345
  // ask / yolo -> the user decides; auto -> approved without asking, which
7530
- // is the whole point of "Never Ask".
8346
+ // is the whole point of "Never Ask". The review resolves to one of
8347
+ // 'approve' | 'edit' | 'keep':
8348
+ // approve — execute the plan as written
8349
+ // edit — open the plan in the editor, then execute the EDITED text
8350
+ // keep — stay in Plan mode, do not execute
7531
8351
  const cur = state.mode || 'ask';
7532
- const ok = cur === 'auto' ? true : await new Promise((resolve) => {
8352
+ let outcome = cur === 'auto' ? 'approve' : await new Promise((resolve) => {
7533
8353
  state.planPending = { plan: planText, resolve };
7534
8354
  renderFrame();
7535
8355
  });
7536
8356
  state.planPending = null;
7537
- if (ok) approvedPlan = planText;
8357
+ if (outcome === 'edit') {
8358
+ // Let the user revise the plan before it is executed. The editor is
8359
+ // synchronous from the agent's point of view: we wait for save.
8360
+ const edited = await new Promise((resolve) => {
8361
+ openEditor({
8362
+ title: 'Review plan — edit before executing',
8363
+ text: planText,
8364
+ caretRow: 0,
8365
+ caretCol: 0,
8366
+ onSave: (value) => resolve(String(value || '').trim()),
8367
+ onCancel: () => resolve(null),
8368
+ });
8369
+ renderFrame();
8370
+ });
8371
+ if (edited) {
8372
+ // Keep the on-screen bubble in sync with the edited text.
8373
+ const bubble = [...state.chat].reverse().find((m) => m.role === 'plan');
8374
+ if (bubble) { bubble.text = edited; bubble._cache = null; }
8375
+ approvedPlan = edited;
8376
+ outcome = 'approve';
8377
+ } else {
8378
+ outcome = 'keep';
8379
+ }
8380
+ }
8381
+ if (outcome === 'approve') {
8382
+ approvedPlan = planText;
8383
+ // Persist the approved plan next to the project so it survives the
8384
+ // session and can be reviewed with the change (or handed to an issue).
8385
+ try {
8386
+ const saved = savePlan(planText, state.workspace || cfg.workspace);
8387
+ if (saved.ok) addChat({ role: 'system', text: `Plan saved: ${saved.path}` });
8388
+ } catch { /* persistence is best-effort */ }
8389
+ }
7538
8390
  }
7539
8391
  }
7540
8392
 
@@ -7577,6 +8429,10 @@ export async function startTUI(opts) {
7577
8429
  // Also clear startAnim if it's still active (should be cleared already by composeFrame)
7578
8430
  state.startAnim = null;
7579
8431
  state.running = false;
8432
+ // The agent may have written or edited files this turn: refresh the git badge
8433
+ // now so the diff totals reflect it immediately, instead of waiting for the
8434
+ // 15s timer.
8435
+ refreshGitInfo(state, { force: true });
7580
8436
 
7581
8437
  if (turnMs > 0) {
7582
8438
  state.lastTurnMs = turnMs;
@@ -7706,6 +8562,7 @@ export async function startTUI(opts) {
7706
8562
  }
7707
8563
  if (proseIdx >= 0) {
7708
8564
  state.chat[proseIdx].text = trimmed;
8565
+ state.chat[proseIdx]._cache = null; // replacement, not append: see fp()
7709
8566
  if (!trimmed.trim()) state.chat.splice(proseIdx, 1); // nothing left: drop it
7710
8567
  } else if (trimmed.trim()) {
7711
8568
  state.chat.unshift({ role: 'assistant', text: trimmed });
@@ -7717,6 +8574,7 @@ export async function startTUI(opts) {
7717
8574
  let last = state.chat[state.chat.length - 1];
7718
8575
  if (last && last.role === 'plan') {
7719
8576
  last.text = planSoFar;
8577
+ last._cache = null; // replacement, not append: see fp()
7720
8578
  } else {
7721
8579
  state.chat.push({ role: 'plan', text: planSoFar });
7722
8580
  }
@@ -7811,6 +8669,36 @@ export async function startTUI(opts) {
7811
8669
  try { stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l' + alternateScreen(false) + showCursor()); } catch {}
7812
8670
  });
7813
8671
 
8672
+ // REMOTE CONTROL (--control): expose a local socket so a script can queue a
8673
+ // prompt on this running session, query its status, or interrupt it. Opt-in —
8674
+ // nothing listens unless the user asked for it. Failures are non-fatal (a
8675
+ // second instance may already hold the socket).
8676
+ let control = null;
8677
+ if (opts.control != null) {
8678
+ try {
8679
+ control = await startControlServer({
8680
+ prompt: async (text) => { await submit(text); },
8681
+ status: async () => ({
8682
+ busy: !!(state.running),
8683
+ session: (session && session.id) || '',
8684
+ model: cfg.model || '',
8685
+ cwd: state.cwd || cfg.workspace || '',
8686
+ queued: (state.queued || []).length,
8687
+ }),
8688
+ interrupt: async () => { if (state.agent && typeof state.agent.interrupt === 'function') state.agent.interrupt(); },
8689
+ }, { path: opts.control || undefined });
8690
+ if (control.error) {
8691
+ try { stdout.write(`\r\n control socket unavailable: ${control.error}\r\n`); } catch {}
8692
+ } else {
8693
+ addChat({ role: 'system', text: `Remote control listening on ${control.path}` });
8694
+ renderFrame();
8695
+ }
8696
+ } catch (e) {
8697
+ try { stdout.write(`\r\n control socket failed: ${e.message}\r\n`); } catch {}
8698
+ }
8699
+ }
8700
+ if (control && control.server) process.on('exit', () => { try { control.close(); } catch {} });
8701
+
7814
8702
  // Keep the process alive for the whole session. startTUI is async and returns
7815
8703
  // as soon as the TUI is wired up; without a pending promise, main()'s await
7816
8704
  // resolves immediately and Node exits (the "flash of UI then quit" bug).