@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/README.md +43 -0
- package/package.json +1 -1
- package/src/agent.js +75 -0
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +7 -0
- package/src/git.js +332 -0
- package/src/hooks.js +247 -0
- package/src/index.js +116 -16
- package/src/llm.js +14 -2
- package/src/mcp.js +376 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +988 -100
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) + '╮',
|
|
2000
|
-
const botRule = PAD + col('╰' + '─'.repeat(boxInner) + '╯',
|
|
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
|
|
2023
|
-
+ `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${
|
|
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('│',
|
|
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
|
|
3037
|
-
//
|
|
3038
|
-
//
|
|
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('
|
|
3047
|
-
// The plan is ALREADY shown in its table above
|
|
3048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('│',
|
|
3358
|
+
CPAD + col('│', shellBorder) + rowAnsi + C.reset + col('│', shellBorder)
|
|
3095
3359
|
);
|
|
3096
3360
|
}
|
|
3097
|
-
lines.push(CPAD + col('╰' + '─'.repeat(cInner) + '╯',
|
|
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
|
-
|
|
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
|
|
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
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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
|
-
|
|
3495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3829
|
+
out.push({ role: 'assistant', text });
|
|
3501
3830
|
for (const tc of (m.toolCalls || [])) {
|
|
3502
|
-
|
|
3831
|
+
out.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
|
|
3503
3832
|
}
|
|
3504
3833
|
} else if (role === 'tool') {
|
|
3505
|
-
|
|
3834
|
+
out.push({ role: 'tool_result', text });
|
|
3506
3835
|
}
|
|
3507
3836
|
}
|
|
3508
|
-
|
|
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
|
-
|
|
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
|
|
3978
|
-
if (
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
const
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
: 'No
|
|
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
|
-
//
|
|
5314
|
-
|
|
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
|
-
|
|
5985
|
-
|
|
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
|
-
|
|
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
|
|
6155
|
-
// Enter = approve
|
|
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
|
-
|
|
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(
|
|
6805
|
+
resolve(outcome);
|
|
6169
6806
|
renderFrame();
|
|
6170
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6972
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
7360
|
-
if (entry.toolName === 'Write')
|
|
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
|
-
|
|
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 (
|
|
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).
|