kc-beta 0.5.5 → 0.6.0
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/QUICKSTART.md +17 -4
- package/README.md +58 -11
- package/bin/kc-beta.js +35 -1
- package/package.json +1 -1
- package/src/agent/bundle-tree.js +553 -0
- package/src/agent/context.js +40 -1
- package/src/agent/engine.js +644 -28
- package/src/agent/llm-client.js +67 -18
- package/src/agent/pipelines/finalization.js +186 -0
- package/src/agent/pipelines/index.js +8 -0
- package/src/agent/pipelines/initializer.js +40 -0
- package/src/agent/pipelines/skill-authoring.js +100 -6
- package/src/agent/skill-loader.js +54 -4
- package/src/agent/task-manager.js +66 -3
- package/src/agent/tools/agent-tool.js +283 -35
- package/src/agent/tools/bundle-search.js +146 -0
- package/src/agent/tools/document-chunk.js +246 -0
- package/src/agent/tools/document-classify.js +311 -0
- package/src/agent/tools/document-parse.js +8 -1
- package/src/agent/tools/phase-advance.js +30 -7
- package/src/agent/tools/registry.js +10 -0
- package/src/agent/tools/rule-catalog.js +17 -3
- package/src/agent/tools/sandbox-exec.js +30 -0
- package/src/agent/workspace.js +168 -14
- package/src/cli/components.js +165 -17
- package/src/cli/index.js +166 -19
- package/src/cli/meme.js +58 -0
- package/src/config.js +39 -2
- package/src/model-tiers.json +3 -2
- package/src/providers.js +34 -1
- package/template/skills/en/meta-meta/evolution-loop/SKILL.md +13 -1
- package/template/skills/en/meta-meta/rule-extraction/SKILL.md +74 -0
- package/template/skills/zh/meta-meta/evolution-loop/SKILL.md +7 -1
- package/template/skills/zh/meta-meta/rule-extraction/SKILL.md +73 -0
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { BaseTool, ToolResult } from "./base.js";
|
|
3
|
+
import { SHARED_COORDINATION_PATHS } from "../workspace.js";
|
|
3
4
|
|
|
4
5
|
const MAX_OUTPUT = 10_000;
|
|
5
6
|
|
|
7
|
+
// H6: detect sandbox_exec commands that touch shared coordination files.
|
|
8
|
+
// Doesn't block — just prepends a warning to the tool result. In session
|
|
9
|
+
// 6304673afaa0 we observed 8+ subagents doing `cat catalog.json | python`
|
|
10
|
+
// and `json.dump()` to overwrite catalog.json directly, racing each other
|
|
11
|
+
// because sandbox_exec bypasses the workspace-file lock (B9). The warning
|
|
12
|
+
// nudges the LLM toward workspace_file / rule_catalog which ARE lock-safe.
|
|
13
|
+
function detectSharedFileWrites(command) {
|
|
14
|
+
if (!command) return [];
|
|
15
|
+
const hits = new Set();
|
|
16
|
+
for (const shared of SHARED_COORDINATION_PATHS) {
|
|
17
|
+
// Match both bare and quoted forms (e.g. rules/catalog.json or "rules/catalog.json")
|
|
18
|
+
const re = new RegExp(shared.replace(/\//g, "\\/").replace(/\./g, "\\."));
|
|
19
|
+
if (re.test(command)) hits.add(shared);
|
|
20
|
+
}
|
|
21
|
+
return Array.from(hits);
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
/**
|
|
7
25
|
* Execute shell commands in the workspace directory.
|
|
8
26
|
* Uses child_process.spawn so pipes, redirects, && all work.
|
|
@@ -59,6 +77,11 @@ export class SandboxExecTool extends BaseTool {
|
|
|
59
77
|
? this._workspace.projectDir
|
|
60
78
|
: this._workspace.cwd;
|
|
61
79
|
|
|
80
|
+
// H6: warn before the command runs when it touches shared files. The
|
|
81
|
+
// warning becomes part of the tool result so the LLM sees it on every
|
|
82
|
+
// subsequent call and self-corrects toward workspace_file / rule_catalog.
|
|
83
|
+
const sharedHits = detectSharedFileWrites(command);
|
|
84
|
+
|
|
62
85
|
try {
|
|
63
86
|
const { output, code } = await this._run(command, effectiveCwd);
|
|
64
87
|
let result = output;
|
|
@@ -68,6 +91,13 @@ export class SandboxExecTool extends BaseTool {
|
|
|
68
91
|
if (code !== 0) {
|
|
69
92
|
result += `\n[exit code: ${code}]`;
|
|
70
93
|
}
|
|
94
|
+
if (sharedHits.length > 0) {
|
|
95
|
+
const prefix =
|
|
96
|
+
`⚠️ This command touches shared coordination file(s): ${sharedHits.join(", ")}.\n` +
|
|
97
|
+
` sandbox_exec writes bypass workspace file locking (B9).\n` +
|
|
98
|
+
` Under concurrent subagents this races — use workspace_file or rule_catalog instead.\n\n`;
|
|
99
|
+
result = prefix + result;
|
|
100
|
+
}
|
|
71
101
|
return new ToolResult(result, code !== 0);
|
|
72
102
|
} catch (err) {
|
|
73
103
|
if (err.message === "timeout") {
|
package/src/agent/workspace.js
CHANGED
|
@@ -8,6 +8,32 @@ import { generateTraceId } from "./version-manager.js";
|
|
|
8
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const GITIGNORE_TEMPLATE = path.resolve(__dirname, "../../template/workspace.gitignore");
|
|
10
10
|
|
|
11
|
+
// B9: Shared coordination files. Any writer touching these paths MUST go
|
|
12
|
+
// through Workspace.withFileLock() so concurrent writers (main agent +
|
|
13
|
+
// subagents + sandbox_exec) serialize. Observed in session 6304673afaa0:
|
|
14
|
+
// 8+ engines rewriting catalog.json in a 60-second window with no
|
|
15
|
+
// coordination, thrashing rule counts 464 → 364 → 736 → 307.
|
|
16
|
+
//
|
|
17
|
+
// Matching is done via path-suffix — a writer passing "rules/catalog.json"
|
|
18
|
+
// or an absolute path ending in rules/catalog.json both match. Subpaths
|
|
19
|
+
// like "rules/manifest.json" also match with their own distinct lock.
|
|
20
|
+
export const SHARED_COORDINATION_PATHS = [
|
|
21
|
+
"rules/catalog.json",
|
|
22
|
+
"rules/manifest.json",
|
|
23
|
+
"refs/manifest.json",
|
|
24
|
+
"session-state.json",
|
|
25
|
+
"tasks.json",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function isSharedCoordinationPath(relOrAbsPath) {
|
|
29
|
+
if (!relOrAbsPath) return false;
|
|
30
|
+
const norm = relOrAbsPath.replace(/\\/g, "/");
|
|
31
|
+
for (const p of SHARED_COORDINATION_PATHS) {
|
|
32
|
+
if (norm === p || norm.endsWith("/" + p)) return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
/**
|
|
12
38
|
* Per-session workspace directory with path traversal protection.
|
|
13
39
|
* Each agent session gets its own directory under the workspace root.
|
|
@@ -92,6 +118,82 @@ export class Workspace {
|
|
|
92
118
|
return resolved;
|
|
93
119
|
}
|
|
94
120
|
|
|
121
|
+
/**
|
|
122
|
+
* B9: Execute `fn` while holding an exclusive lock on `relPath`. Use for
|
|
123
|
+
* any shared coordination file that multiple engines (main + subagents +
|
|
124
|
+
* sandbox_exec) could write. The lock is implemented as a sibling
|
|
125
|
+
* `<resolved>.lock` file created atomically via `O_CREAT | O_EXCL`,
|
|
126
|
+
* which is POSIX-guaranteed-atomic on any local filesystem. Stale
|
|
127
|
+
* lockfiles (mtime older than `staleMs`) are force-removed so a crashed
|
|
128
|
+
* holder doesn't block the whole workspace forever.
|
|
129
|
+
*
|
|
130
|
+
* Subagents and the parent share the same workspace directory, so their
|
|
131
|
+
* lockfiles collide correctly — no special IPC needed. Works across
|
|
132
|
+
* engine instances in the same Node process (async contention) and
|
|
133
|
+
* across Node processes (sibling CLI invocations).
|
|
134
|
+
*
|
|
135
|
+
* @template T
|
|
136
|
+
* @param {string} relPath - workspace-relative path being protected
|
|
137
|
+
* @param {() => Promise<T>|T} fn - critical section
|
|
138
|
+
* @param {{timeoutMs?: number, retryMs?: number, staleMs?: number}} [opts]
|
|
139
|
+
* @returns {Promise<T>}
|
|
140
|
+
*/
|
|
141
|
+
async withFileLock(relPath, fn, { timeoutMs = 10_000, retryMs = 50, staleMs = 60_000 } = {}) {
|
|
142
|
+
const target = this.resolvePath(relPath);
|
|
143
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
144
|
+
const lockPath = target + ".lock";
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
|
|
147
|
+
while (true) {
|
|
148
|
+
let fd;
|
|
149
|
+
try {
|
|
150
|
+
fd = fs.openSync(lockPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (e.code !== "EEXIST") throw e;
|
|
153
|
+
// Lockfile exists. Check if stale — a crashed holder left it behind.
|
|
154
|
+
try {
|
|
155
|
+
const st = fs.statSync(lockPath);
|
|
156
|
+
if (Date.now() - st.mtimeMs > staleMs) {
|
|
157
|
+
try { fs.unlinkSync(lockPath); } catch { /* raced against another stealer */ }
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Lockfile vanished between EEXIST and stat — retry to acquire.
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (Date.now() - start > timeoutMs) {
|
|
165
|
+
throw new Error(`Timeout acquiring lock on ${relPath} after ${timeoutMs}ms (held by another engine)`);
|
|
166
|
+
}
|
|
167
|
+
await new Promise((r) => setTimeout(r, retryMs));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Acquired. Record holder metadata inside the lockfile — useful for
|
|
172
|
+
// debugging: `cat rules/catalog.json.lock` shows pid + acquired-at.
|
|
173
|
+
try {
|
|
174
|
+
fs.writeSync(fd, Buffer.from(`${process.pid}|${Date.now()}|${this.sessionId}\n`));
|
|
175
|
+
} catch { /* best-effort */ }
|
|
176
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
return await fn();
|
|
180
|
+
} finally {
|
|
181
|
+
try { fs.unlinkSync(lockPath); } catch { /* already gone, stale-reaped, or permission issue */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Convenience: run `fn` under a shared-coordination lock when the path
|
|
188
|
+
* is a known shared file. Otherwise runs `fn` directly without lock.
|
|
189
|
+
* Lets callsites uniformly wrap their writes without knowing which
|
|
190
|
+
* paths are shared.
|
|
191
|
+
*/
|
|
192
|
+
async withSharedLockIfApplicable(relPath, fn) {
|
|
193
|
+
if (isSharedCoordinationPath(relPath)) return this.withFileLock(relPath, fn);
|
|
194
|
+
return fn();
|
|
195
|
+
}
|
|
196
|
+
|
|
95
197
|
/**
|
|
96
198
|
* Auto-commit a workspace write. Silently no-ops if the path is gitignored,
|
|
97
199
|
* if there's nothing to commit, or if git isn't available. Returns the trace
|
|
@@ -108,22 +210,74 @@ export class Workspace {
|
|
|
108
210
|
|
|
109
211
|
if (!this._gitAvailable) return traceId;
|
|
110
212
|
|
|
213
|
+
// B5: Serialize concurrent git operations. Two sub-agents committing
|
|
214
|
+
// at the same time used to race on .git/index.lock — one would fail
|
|
215
|
+
// silently ("fatal: Unable to create '.git/index.lock': File exists"),
|
|
216
|
+
// its auto-commit lost but its workspace write still on disk so
|
|
217
|
+
// downstream readers see the change without commit history.
|
|
218
|
+
//
|
|
219
|
+
// withGitSyncLock is a synchronous best-effort wrapper around
|
|
220
|
+
// withFileLock — using flock-on-sibling-.lock-file semantics but
|
|
221
|
+
// implemented inline since autoCommit is sync. A dedicated gitlock
|
|
222
|
+
// file (not a real git lockfile) coordinates across engines.
|
|
223
|
+
this._withGitSyncLock(() => {
|
|
224
|
+
try {
|
|
225
|
+
const r = spawnSync("git", ["add", "--", relPath], {
|
|
226
|
+
cwd: this.path,
|
|
227
|
+
stdio: "ignore",
|
|
228
|
+
});
|
|
229
|
+
if (r.status !== 0) return; // gitignored or other add error — skip commit
|
|
230
|
+
const msg = `[${this._currentPhase}] ${opLabel} ${relPath} [trace:${traceId}]`;
|
|
231
|
+
spawnSync("git", ["commit", "-m", msg, "--allow-empty-message"], {
|
|
232
|
+
cwd: this.path,
|
|
233
|
+
stdio: "ignore",
|
|
234
|
+
});
|
|
235
|
+
// Status doesn't matter — "nothing to commit" is fine.
|
|
236
|
+
} catch {
|
|
237
|
+
// Never let a git failure break a workspace write.
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return traceId;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* B5: Synchronous gitops lock. Mirror of withFileLock but sync to fit
|
|
245
|
+
* autoCommit's existing call signature. Times out and proceeds anyway
|
|
246
|
+
* after 5s — we'd rather lose one commit than deadlock a write.
|
|
247
|
+
*/
|
|
248
|
+
_withGitSyncLock(fn, { timeoutMs = 5_000, staleMs = 30_000 } = {}) {
|
|
249
|
+
const lockPath = path.join(this.path, ".git", "kc-commit.lock");
|
|
250
|
+
const start = Date.now();
|
|
251
|
+
let acquired = false;
|
|
252
|
+
while (Date.now() - start < timeoutMs) {
|
|
253
|
+
try {
|
|
254
|
+
const fd = fs.openSync(lockPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
255
|
+
try { fs.writeSync(fd, Buffer.from(`${process.pid}|${Date.now()}\n`)); } catch { /* best effort */ }
|
|
256
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
257
|
+
acquired = true;
|
|
258
|
+
break;
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if (e.code !== "EEXIST" && e.code !== "ENOENT") break; // ENOENT: .git dir missing — proceed unlocked
|
|
261
|
+
// Check for stale
|
|
262
|
+
try {
|
|
263
|
+
const st = fs.statSync(lockPath);
|
|
264
|
+
if (Date.now() - st.mtimeMs > staleMs) {
|
|
265
|
+
try { fs.unlinkSync(lockPath); } catch { /* race with another stealer */ }
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
continue; // vanished, retry
|
|
270
|
+
}
|
|
271
|
+
// Busy-wait briefly
|
|
272
|
+
const deadline = Date.now() + 20;
|
|
273
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
111
276
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
if (r.status !== 0) return traceId; // gitignored or other add error — skip commit
|
|
117
|
-
const msg = `[${this._currentPhase}] ${opLabel} ${relPath} [trace:${traceId}]`;
|
|
118
|
-
spawnSync("git", ["commit", "-m", msg, "--allow-empty-message"], {
|
|
119
|
-
cwd: this.path,
|
|
120
|
-
stdio: "ignore",
|
|
121
|
-
});
|
|
122
|
-
// Status doesn't matter — "nothing to commit" is fine.
|
|
123
|
-
} catch {
|
|
124
|
-
// Never let a git failure break a workspace write.
|
|
277
|
+
fn();
|
|
278
|
+
} finally {
|
|
279
|
+
if (acquired) { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } }
|
|
125
280
|
}
|
|
126
|
-
return traceId;
|
|
127
281
|
}
|
|
128
282
|
|
|
129
283
|
/**
|
package/src/cli/components.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp, useStdout } from "ink";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
3
6
|
|
|
4
7
|
const h = React.createElement;
|
|
5
8
|
|
|
9
|
+
// A4: Resolve once at module load (package.json doesn't change mid-session).
|
|
10
|
+
// Lazy-safe via try/catch so dev-mode from odd cwd never breaks the TUI.
|
|
11
|
+
const KC_VERSION = (() => {
|
|
12
|
+
try {
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkg = JSON.parse(readFileSync(resolve(here, "..", "..", "package.json"), "utf-8"));
|
|
15
|
+
return pkg.version;
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
})();
|
|
18
|
+
|
|
6
19
|
// --- Cooking spinner ---
|
|
7
20
|
|
|
8
21
|
const COOKING_WORDS = [
|
|
@@ -32,15 +45,36 @@ export function CookingSpinner({ status }) {
|
|
|
32
45
|
|
|
33
46
|
const LENAT_QUOTE = "Intelligence is ten million rules.";
|
|
34
47
|
|
|
48
|
+
// F7: rolling 30-sample window for CTX smoothing + peak tracking. 30
|
|
49
|
+
// samples × observed update cadence (~1/sec during active turns) ≈ a
|
|
50
|
+
// 30-second smoothed view, which absorbs the small spikes from
|
|
51
|
+
// transient tool-result embeddings that made the old "instantaneous"
|
|
52
|
+
// display jumpy. Peak stays at the highest seen this session.
|
|
53
|
+
const CTX_SAMPLE_WINDOW = 30;
|
|
54
|
+
|
|
35
55
|
export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
|
|
36
|
-
const
|
|
56
|
+
const samplesRef = useRef([]);
|
|
57
|
+
const peakRef = useRef(0);
|
|
58
|
+
|
|
59
|
+
// Push current sample + cap the ring
|
|
60
|
+
const samples = samplesRef.current;
|
|
61
|
+
samples.push(contextTokens || 0);
|
|
62
|
+
if (samples.length > CTX_SAMPLE_WINDOW) samples.shift();
|
|
63
|
+
const smoothed = samples.length > 0
|
|
64
|
+
? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length)
|
|
65
|
+
: 0;
|
|
66
|
+
if ((contextTokens || 0) > peakRef.current) peakRef.current = contextTokens || 0;
|
|
67
|
+
const peak = peakRef.current;
|
|
68
|
+
|
|
69
|
+
const pct = contextLimit ? Math.round((smoothed / contextLimit) * 100) : 0;
|
|
37
70
|
const ctxColor = pct > 80 ? "red" : pct > 60 ? "yellow" : "green";
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
const fmt = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n || 0}`;
|
|
72
|
+
const ctxLabel = fmt(smoothed);
|
|
73
|
+
const limitLabel = fmt(contextLimit || 0);
|
|
74
|
+
// F7: peak shown when meaningfully higher than smoothed (by at least
|
|
75
|
+
// 5% of the limit) so users see "we hit high water, currently back
|
|
76
|
+
// down." Otherwise skip to keep the bar compact.
|
|
77
|
+
const showPeak = contextLimit > 0 && (peak - smoothed) / contextLimit > 0.05;
|
|
44
78
|
|
|
45
79
|
// Soft-threshold hint — shows up before auto-windowing kicks in at ~70%
|
|
46
80
|
// so users know they can run /compact to reduce context more aggressively
|
|
@@ -55,6 +89,7 @@ export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
|
|
|
55
89
|
phase ? h(Text, { color: "cyan" }, ` ${phase.toUpperCase()}`) : null,
|
|
56
90
|
h(Text, { color: "green" }, " ● "),
|
|
57
91
|
h(Text, { color: ctxColor }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
|
|
92
|
+
showPeak ? h(Text, { dimColor: true }, ` · peak ${fmt(peak)}`) : null,
|
|
58
93
|
compactHint ? h(Text, { color: ctxColor }, compactHint) : null,
|
|
59
94
|
h(Text, { dimColor: true }, ` · ${LENAT_QUOTE}`),
|
|
60
95
|
);
|
|
@@ -100,7 +135,7 @@ export function WelcomeBanner({ projectDir, pendingInputCount = 0 } = {}) {
|
|
|
100
135
|
return h(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "gray", paddingLeft: 1, paddingRight: 1 },
|
|
101
136
|
h(Box, null,
|
|
102
137
|
h(Text, { bold: true }, "KC AGENT CLI"),
|
|
103
|
-
h(Text, { dimColor: true }, " (beta)"),
|
|
138
|
+
h(Text, { dimColor: true }, KC_VERSION ? ` v${KC_VERSION} (beta)` : " (beta)"),
|
|
104
139
|
),
|
|
105
140
|
h(Text, { dimColor: true }, "Hope you never know what KC was."),
|
|
106
141
|
h(Text, null, ""),
|
|
@@ -114,6 +149,17 @@ export function WelcomeBanner({ projectDir, pendingInputCount = 0 } = {}) {
|
|
|
114
149
|
? h(Text, { color: "cyan" }, `📥 ${pendingInputCount} new file(s) pending in input/ — run /schedule for details`)
|
|
115
150
|
: null,
|
|
116
151
|
h(Text, null, ""),
|
|
152
|
+
// H7: priority-phrasing nudge. If the developer has multiple inputs
|
|
153
|
+
// with mixed roles (authoritative vs supporting), KC treats them
|
|
154
|
+
// equally unless told otherwise — which led to the reg 02 starvation
|
|
155
|
+
// + reg 03-10 bloat in session 6304673afaa0. Prompt explicit up-front.
|
|
156
|
+
h(Text, { dimColor: true, color: "cyan" },
|
|
157
|
+
"💡 Tip: If your inputs include BOTH authoritative and supporting"),
|
|
158
|
+
h(Text, { dimColor: true, color: "cyan" },
|
|
159
|
+
" sources, say so at session start — e.g. \"prioritize rules/01 and"),
|
|
160
|
+
h(Text, { dimColor: true, color: "cyan" },
|
|
161
|
+
" rules/02 as core; rules/03-10 are supporting context only.\""),
|
|
162
|
+
h(Text, null, ""),
|
|
117
163
|
h(Text, { dimColor: true }, "Product of Memium / kitchen-engineer42"),
|
|
118
164
|
);
|
|
119
165
|
}
|
|
@@ -134,7 +180,14 @@ export function WelcomeBanner({ projectDir, pendingInputCount = 0 } = {}) {
|
|
|
134
180
|
*/
|
|
135
181
|
const RECENT_PREVIEW_LINES = 4;
|
|
136
182
|
|
|
137
|
-
|
|
183
|
+
// B0.5: React.memo — ToolBlock renders the heaviest subtree in the TUI
|
|
184
|
+
// (multi-line Box + colored Text + per-line Box wrappers). Without memo,
|
|
185
|
+
// every `setMessages` / `setStreamingText` causes React to re-render ALL
|
|
186
|
+
// 50 visible ToolBlocks even though none of their props changed. Ink
|
|
187
|
+
// then diffs the result against the prior render. Memo lets us skip
|
|
188
|
+
// that work for untouched rows. The props are small primitives + short
|
|
189
|
+
// strings — shallow equality is the right comparator.
|
|
190
|
+
function ToolBlockImpl({ name, input, output, isError, isRunning, isRecent = true }) {
|
|
138
191
|
const borderColor = isRunning ? "yellow" : isError ? "red" : "green";
|
|
139
192
|
const outStr = typeof output === "string" ? output : "";
|
|
140
193
|
const lines = outStr ? outStr.split("\n") : [];
|
|
@@ -191,6 +244,8 @@ export function ToolBlock({ name, input, output, isError, isRunning, isRecent =
|
|
|
191
244
|
);
|
|
192
245
|
}
|
|
193
246
|
|
|
247
|
+
export const ToolBlock = React.memo(ToolBlockImpl);
|
|
248
|
+
|
|
194
249
|
// --- Message display ---
|
|
195
250
|
|
|
196
251
|
export function MessageBlock({ role, content, toolName, toolInput, toolOutput, toolIsError }) {
|
|
@@ -242,29 +297,122 @@ export function MessagesList({ messages }) {
|
|
|
242
297
|
|
|
243
298
|
// --- Input prompt ---
|
|
244
299
|
|
|
245
|
-
|
|
300
|
+
/**
|
|
301
|
+
* F3: cursor-aware input with arrow-key support.
|
|
302
|
+
*
|
|
303
|
+
* - Left/Right: move cursor within the current line. Cursor position is
|
|
304
|
+
* internal state (not hoisted) so the parent's onChange contract
|
|
305
|
+
* stays stable.
|
|
306
|
+
* - Up/Down: when the input is empty (OR cursor is at start/end), walk
|
|
307
|
+
* through a session-local history buffer of the user's past
|
|
308
|
+
* submissions. Non-destructive: editing a recalled line doesn't mutate
|
|
309
|
+
* the history entry.
|
|
310
|
+
* - Home/End (or Ctrl-A/Ctrl-E): jump cursor to start/end.
|
|
311
|
+
* - Backspace/Delete: deletes at cursor position (not always end-of-line).
|
|
312
|
+
*
|
|
313
|
+
* History is in-memory only (`historyRef`) — not persisted across sessions,
|
|
314
|
+
* per v0.6.0 plan item F3 "keep simple." Cleared on `/clear`.
|
|
315
|
+
*/
|
|
316
|
+
export function InputPrompt({ value, onChange, onSubmit, isActive, placeholderRight = null }) {
|
|
317
|
+
const [cursor, setCursor] = useState(value.length);
|
|
318
|
+
const historyRef = useRef([]); // session-local submission history
|
|
319
|
+
const historyIdxRef = useRef(null); // index while browsing history; null = live editing
|
|
320
|
+
|
|
321
|
+
// Keep cursor in range when value changes externally (e.g. recall).
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (cursor > value.length) setCursor(value.length);
|
|
324
|
+
}, [value, cursor]);
|
|
325
|
+
|
|
246
326
|
useInput((input, key) => {
|
|
247
327
|
if (!isActive) return;
|
|
248
328
|
|
|
329
|
+
// Submit
|
|
249
330
|
if (key.return) {
|
|
250
|
-
|
|
331
|
+
const v = value;
|
|
332
|
+
if (v.trim()) historyRef.current.push(v);
|
|
333
|
+
historyIdxRef.current = null;
|
|
334
|
+
setCursor(0);
|
|
335
|
+
onSubmit(v);
|
|
251
336
|
return;
|
|
252
337
|
}
|
|
338
|
+
|
|
339
|
+
// Backspace at cursor
|
|
253
340
|
if (key.backspace || key.delete) {
|
|
254
|
-
|
|
341
|
+
if (cursor === 0) return;
|
|
342
|
+
const next = value.slice(0, cursor - 1) + value.slice(cursor);
|
|
343
|
+
onChange(next);
|
|
344
|
+
setCursor(cursor - 1);
|
|
345
|
+
historyIdxRef.current = null;
|
|
255
346
|
return;
|
|
256
347
|
}
|
|
257
|
-
|
|
348
|
+
|
|
349
|
+
// Arrow keys
|
|
350
|
+
if (key.leftArrow) {
|
|
351
|
+
if (cursor > 0) setCursor(cursor - 1);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (key.rightArrow) {
|
|
355
|
+
if (cursor < value.length) setCursor(cursor + 1);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (key.upArrow) {
|
|
359
|
+
const hist = historyRef.current;
|
|
360
|
+
if (hist.length === 0) return;
|
|
361
|
+
const idx = historyIdxRef.current == null ? hist.length : historyIdxRef.current;
|
|
362
|
+
const nextIdx = Math.max(0, idx - 1);
|
|
363
|
+
historyIdxRef.current = nextIdx;
|
|
364
|
+
const recalled = hist[nextIdx] || "";
|
|
365
|
+
onChange(recalled);
|
|
366
|
+
setCursor(recalled.length);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (key.downArrow) {
|
|
370
|
+
const hist = historyRef.current;
|
|
371
|
+
if (historyIdxRef.current == null) return;
|
|
372
|
+
const nextIdx = historyIdxRef.current + 1;
|
|
373
|
+
if (nextIdx >= hist.length) {
|
|
374
|
+
historyIdxRef.current = null;
|
|
375
|
+
onChange("");
|
|
376
|
+
setCursor(0);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
historyIdxRef.current = nextIdx;
|
|
380
|
+
const recalled = hist[nextIdx] || "";
|
|
381
|
+
onChange(recalled);
|
|
382
|
+
setCursor(recalled.length);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Home/End — Ctrl-A / Ctrl-E (terminal convention)
|
|
387
|
+
if (key.ctrl && input === "a") { setCursor(0); return; }
|
|
388
|
+
if (key.ctrl && input === "e") { setCursor(value.length); return; }
|
|
389
|
+
|
|
390
|
+
// Skip other control combos
|
|
258
391
|
if (key.ctrl || key.meta || key.escape) return;
|
|
259
|
-
|
|
392
|
+
|
|
393
|
+
// Printable characters insert at cursor
|
|
260
394
|
if (input) {
|
|
261
|
-
|
|
395
|
+
const next = value.slice(0, cursor) + input + value.slice(cursor);
|
|
396
|
+
onChange(next);
|
|
397
|
+
setCursor(cursor + input.length);
|
|
398
|
+
historyIdxRef.current = null;
|
|
262
399
|
}
|
|
263
400
|
}, { isActive });
|
|
264
401
|
|
|
402
|
+
// Render: split the value at the cursor so the block-cursor appears
|
|
403
|
+
// inline, not just at the end.
|
|
404
|
+
const before = value.slice(0, cursor);
|
|
405
|
+
const after = value.slice(cursor);
|
|
406
|
+
|
|
265
407
|
return h(Box, null,
|
|
266
408
|
h(Text, { dimColor: true }, "❯ "),
|
|
267
|
-
h(Text, null,
|
|
268
|
-
isActive
|
|
409
|
+
h(Text, null, before),
|
|
410
|
+
isActive
|
|
411
|
+
? h(Text, { color: "gray", inverse: true }, after.length > 0 ? after[0] : " ")
|
|
412
|
+
: null,
|
|
413
|
+
h(Text, null, after.length > 0 ? after.slice(1) : ""),
|
|
414
|
+
placeholderRight
|
|
415
|
+
? h(Box, { marginLeft: 2 }, h(Text, { dimColor: true, color: "cyan" }, placeholderRight))
|
|
416
|
+
: null,
|
|
269
417
|
);
|
|
270
418
|
}
|