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.
Files changed (34) hide show
  1. package/QUICKSTART.md +17 -4
  2. package/README.md +58 -11
  3. package/bin/kc-beta.js +35 -1
  4. package/package.json +1 -1
  5. package/src/agent/bundle-tree.js +553 -0
  6. package/src/agent/context.js +40 -1
  7. package/src/agent/engine.js +644 -28
  8. package/src/agent/llm-client.js +67 -18
  9. package/src/agent/pipelines/finalization.js +186 -0
  10. package/src/agent/pipelines/index.js +8 -0
  11. package/src/agent/pipelines/initializer.js +40 -0
  12. package/src/agent/pipelines/skill-authoring.js +100 -6
  13. package/src/agent/skill-loader.js +54 -4
  14. package/src/agent/task-manager.js +66 -3
  15. package/src/agent/tools/agent-tool.js +283 -35
  16. package/src/agent/tools/bundle-search.js +146 -0
  17. package/src/agent/tools/document-chunk.js +246 -0
  18. package/src/agent/tools/document-classify.js +311 -0
  19. package/src/agent/tools/document-parse.js +8 -1
  20. package/src/agent/tools/phase-advance.js +30 -7
  21. package/src/agent/tools/registry.js +10 -0
  22. package/src/agent/tools/rule-catalog.js +17 -3
  23. package/src/agent/tools/sandbox-exec.js +30 -0
  24. package/src/agent/workspace.js +168 -14
  25. package/src/cli/components.js +165 -17
  26. package/src/cli/index.js +166 -19
  27. package/src/cli/meme.js +58 -0
  28. package/src/config.js +39 -2
  29. package/src/model-tiers.json +3 -2
  30. package/src/providers.js +34 -1
  31. package/template/skills/en/meta-meta/evolution-loop/SKILL.md +13 -1
  32. package/template/skills/en/meta-meta/rule-extraction/SKILL.md +74 -0
  33. package/template/skills/zh/meta-meta/evolution-loop/SKILL.md +7 -1
  34. 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") {
@@ -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
- const r = spawnSync("git", ["add", "--", relPath], {
113
- cwd: this.path,
114
- stdio: "ignore",
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
  /**
@@ -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 pct = contextLimit ? Math.round((contextTokens / contextLimit) * 100) : 0;
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 ctxLabel = contextTokens >= 1000
39
- ? `${(contextTokens / 1000).toFixed(1)}k`
40
- : `${contextTokens || 0}`;
41
- const limitLabel = contextLimit >= 1000
42
- ? `${(contextLimit / 1000).toFixed(0)}k`
43
- : `${contextLimit || 0}`;
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
- export function ToolBlock({ name, input, output, isError, isRunning, isRecent = true }) {
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
- export function InputPrompt({ value, onChange, onSubmit, isActive }) {
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
- onSubmit(value);
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
- onChange(value.slice(0, -1));
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
- // Skip control characters
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
- // Append printable characters
392
+
393
+ // Printable characters insert at cursor
260
394
  if (input) {
261
- onChange(value + input);
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, value),
268
- isActive ? h(Text, { color: "gray" }, "█") : null,
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
  }