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