klyro 1.0.20 → 1.0.21
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/dist/agent/anthropic-adapter.js +9 -1
- package/dist/agent/child-worker.js +21 -7
- package/dist/agent/orchestrator.js +27 -4
- package/dist/agent/provider-adapter.js +5 -0
- package/dist/agent/retry.js +6 -3
- package/dist/agent/runtime.js +13 -6
- package/dist/agent/worktree-manager.d.ts +2 -0
- package/dist/agent/worktree-manager.js +9 -2
- package/dist/checkpoints/store.js +26 -2
- package/dist/cli/dotenv.js +1 -1
- package/dist/cli/repl.js +6 -3
- package/dist/eval/harness.js +8 -3
- package/dist/mcp/remote.js +31 -7
- package/dist/mcp/sse.js +0 -1
- package/dist/persistence/audit.d.ts +0 -6
- package/dist/persistence/audit.js +11 -18
- package/dist/persistence/store.js +19 -13
- package/dist/policy/engine.js +43 -7
- package/dist/providers/model-info.d.ts +4 -1
- package/dist/providers/model-info.js +10 -2
- package/dist/repl.js +4 -2
- package/dist/tools/git/run-git.js +23 -4
- package/dist/tools/shell/background.js +52 -23
- package/dist/tools/verify/run-verify.js +11 -1
- package/dist/tui/app.d.ts +5 -0
- package/dist/tui/app.js +156 -51
- package/dist/tui/app.test.js +21 -0
- package/dist/tui/diff-parser.js +28 -5
- package/dist/tui/markdown.js +1 -1
- package/dist/tui/measure.js +4 -1
- package/dist/tui/scroll-model.d.ts +2 -0
- package/dist/tui/scroll-model.js +5 -0
- package/dist/tui/status.d.ts +3 -0
- package/dist/verification/engine.js +35 -2
- package/dist/verification/scoped.js +20 -5
- package/package.json +1 -1
|
@@ -4,13 +4,32 @@ export function runGit(args, cwd, timeoutMs = 15_000) {
|
|
|
4
4
|
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
|
|
5
5
|
const out = [];
|
|
6
6
|
const err = [];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
let done = false;
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
if (done)
|
|
10
|
+
return;
|
|
11
|
+
done = true;
|
|
12
|
+
try {
|
|
13
|
+
child.kill('SIGKILL');
|
|
14
|
+
}
|
|
15
|
+
catch { /* noop */ }
|
|
16
|
+
resolve({ code: 1, out: Buffer.concat(out).toString('utf-8'), err: 'git timed out' });
|
|
17
|
+
}, timeoutMs);
|
|
11
18
|
child.stdout.on('data', (b) => out.push(b));
|
|
12
19
|
child.stderr.on('data', (b) => err.push(b));
|
|
20
|
+
// Missing git binary / bad cwd emits 'error' with no 'close' — without
|
|
21
|
+
// this every caller hangs to its own timeout instead of failing fast.
|
|
22
|
+
child.on('error', (e) => {
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
done = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve({ code: 1, out: Buffer.concat(out).toString('utf-8'), err: String(e?.message ?? e) });
|
|
28
|
+
});
|
|
13
29
|
child.on('close', (code) => {
|
|
30
|
+
if (done)
|
|
31
|
+
return;
|
|
32
|
+
done = true;
|
|
14
33
|
clearTimeout(timer);
|
|
15
34
|
resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
|
|
16
35
|
});
|
|
@@ -1,21 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
3
|
*/
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { findBlockedReason, filteredEnv } from './shell-exec.js';
|
|
6
6
|
const jobs = new Map();
|
|
7
7
|
let counter = 0;
|
|
8
8
|
const MAX_JOBS = 5;
|
|
9
9
|
const JOB_TTL_MS = 10 * 60 * 1000;
|
|
10
|
+
/** Tree-kill: proc.kill() alone leaves grandchildren behind (shell:true). */
|
|
11
|
+
function killTree(proc) {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
if (proc.pid !== undefined) {
|
|
15
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { windowsHide: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else if (proc.pid !== undefined) {
|
|
19
|
+
// Detached group kill when we own the group, else single kill.
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-proc.pid, 'SIGKILL');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
try {
|
|
25
|
+
proc.kill('SIGKILL');
|
|
26
|
+
}
|
|
27
|
+
catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
try {
|
|
32
|
+
proc.kill('SIGKILL');
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore */ }
|
|
38
|
+
}
|
|
39
|
+
function watchJob(job) {
|
|
40
|
+
// A failed spawn (bad cwd, missing shell) emits 'error' with no 'close'
|
|
41
|
+
// guarantee — without a listener Node throws and takes down the process.
|
|
42
|
+
job.proc.on('error', (e) => {
|
|
43
|
+
job.output += `\n[spawn failed: ${e?.message ?? e}]`;
|
|
44
|
+
});
|
|
45
|
+
job.proc.on('close', () => {
|
|
46
|
+
setTimeout(() => { if (jobs.get(job.id)?.proc.exitCode !== null)
|
|
47
|
+
jobs.delete(job.id); }, JOB_TTL_MS);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
10
50
|
function pruneJobs() {
|
|
11
51
|
if (jobs.size <= MAX_JOBS)
|
|
12
52
|
return;
|
|
13
53
|
const sorted = [...jobs.values()].sort((a, b) => a.start - b.start);
|
|
14
54
|
for (const j of sorted.slice(0, jobs.size - MAX_JOBS)) {
|
|
15
|
-
|
|
16
|
-
j.proc.kill('SIGKILL');
|
|
17
|
-
}
|
|
18
|
-
catch { /* ignore */ }
|
|
55
|
+
killTree(j.proc);
|
|
19
56
|
jobs.delete(j.id);
|
|
20
57
|
}
|
|
21
58
|
for (const j of [...jobs.values()]) {
|
|
@@ -33,18 +70,19 @@ export function startBackground(command, cwd) {
|
|
|
33
70
|
throw new Error(`Command blocked: ${blocked}`);
|
|
34
71
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
35
72
|
// Filtered env, same call shape as shell_exec — background children must
|
|
36
|
-
// not inherit secrets from the parent process.
|
|
37
|
-
|
|
73
|
+
// not inherit secrets from the parent process. Detached on POSIX so
|
|
74
|
+
// killTree can take the whole group (mirrors shell_exec).
|
|
75
|
+
const proc = spawn(command, {
|
|
76
|
+
cwd, shell: true, windowsHide: true, env: filteredEnv(),
|
|
77
|
+
...(process.platform === 'win32' ? {} : { detached: true }),
|
|
78
|
+
});
|
|
38
79
|
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
39
80
|
jobs.set(id, job);
|
|
40
81
|
proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
41
82
|
job.output = job.output.slice(-1_000_000); });
|
|
42
83
|
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
43
84
|
job.output = job.output.slice(-1_000_000); });
|
|
44
|
-
|
|
45
|
-
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
46
|
-
jobs.delete(id); }, JOB_TTL_MS);
|
|
47
|
-
});
|
|
85
|
+
watchJob(job);
|
|
48
86
|
return id;
|
|
49
87
|
}
|
|
50
88
|
/**
|
|
@@ -66,10 +104,7 @@ export function startBackgroundArgv(argv, cwd) {
|
|
|
66
104
|
job.output = job.output.slice(-1_000_000); });
|
|
67
105
|
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
68
106
|
job.output = job.output.slice(-1_000_000); });
|
|
69
|
-
|
|
70
|
-
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
71
|
-
jobs.delete(id); }, JOB_TTL_MS);
|
|
72
|
-
});
|
|
107
|
+
watchJob(job);
|
|
73
108
|
return id;
|
|
74
109
|
}
|
|
75
110
|
export function getOutput(id, filter) {
|
|
@@ -84,10 +119,7 @@ export function killJob(id) {
|
|
|
84
119
|
const job = jobs.get(id);
|
|
85
120
|
if (!job)
|
|
86
121
|
throw new Error(`No job ${id}`);
|
|
87
|
-
|
|
88
|
-
job.proc.kill('SIGKILL');
|
|
89
|
-
}
|
|
90
|
-
catch { /* ignore */ }
|
|
122
|
+
killTree(job.proc);
|
|
91
123
|
jobs.delete(id);
|
|
92
124
|
}
|
|
93
125
|
/**
|
|
@@ -98,10 +130,7 @@ export function killJob(id) {
|
|
|
98
130
|
export function killAllJobs() {
|
|
99
131
|
const killed = [];
|
|
100
132
|
for (const job of [...jobs.values()]) {
|
|
101
|
-
|
|
102
|
-
job.proc.kill('SIGKILL');
|
|
103
|
-
}
|
|
104
|
-
catch { /* ignore */ }
|
|
133
|
+
killTree(job.proc);
|
|
105
134
|
jobs.delete(job.id);
|
|
106
135
|
killed.push(job.id);
|
|
107
136
|
}
|
|
@@ -10,6 +10,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { defineTool } from '../types.js';
|
|
12
12
|
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
13
|
+
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
13
14
|
import { filteredVerifyEnv } from '../../verification/registry.js';
|
|
14
15
|
const InputSchema = z.object({
|
|
15
16
|
command: z.string().min(1).describe('Verification command, e.g. "npm test" or "tsc --noEmit"'),
|
|
@@ -24,7 +25,16 @@ export const runVerifyTool = defineTool({
|
|
|
24
25
|
inputSchema: InputSchema,
|
|
25
26
|
execute: async (input, ctx) => {
|
|
26
27
|
return safe(async () => {
|
|
27
|
-
|
|
28
|
+
// Contain the model-supplied cwd to the workspace (mirrors shell_exec):
|
|
29
|
+
// without this the verify command could run in /etc, ~/.ssh, or any
|
|
30
|
+
// other directory outside the sandbox roots.
|
|
31
|
+
let cwd;
|
|
32
|
+
try {
|
|
33
|
+
cwd = resolveWithinCwd(ctx.cwd, input.cwd ?? '.').resolved;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw Object.assign(new Error('run_verify cwd escapes workspace (POLICY_DENIED)'), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
|
|
37
|
+
}
|
|
28
38
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
29
39
|
// S2: verify commands run with filtered env; servers needing keys must use explicit config.
|
|
30
40
|
const env = filteredVerifyEnv();
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -37,4 +37,9 @@ export interface AppProps {
|
|
|
37
37
|
version?: string;
|
|
38
38
|
isFullscreen?: boolean;
|
|
39
39
|
}
|
|
40
|
+
declare const MAX_TRANSCRIPT_ITEMS = 500;
|
|
41
|
+
export { MAX_TRANSCRIPT_ITEMS as TRANSCRIPT_ITEM_CAP };
|
|
42
|
+
export declare function capTranscript<T extends {
|
|
43
|
+
kind: string;
|
|
44
|
+
}>(prev: T[]): T[];
|
|
40
45
|
export declare function App(props: AppProps): React.JSX.Element;
|
package/dist/tui/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
11
|
import * as path from 'node:path';
|
|
12
12
|
import { TuiApprovalBridge, ApprovalModal } from './approval.js';
|
|
13
|
+
import { approvalChoiceForKey } from '../policy/approval.js';
|
|
13
14
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
14
15
|
import { loadCustomCommands, listCompletableFiles } from '../cli/slash/custom.js';
|
|
15
16
|
import { loadMcpServers } from '../mcp/config.js';
|
|
@@ -136,10 +137,10 @@ function InputWithCursor({ input, cursor }) {
|
|
|
136
137
|
if (cursor === null) {
|
|
137
138
|
return _jsxs(Text, { wrap: "wrap", children: [safe, "|"] });
|
|
138
139
|
}
|
|
139
|
-
const pos = Math.max(0, Math.min(cursor, safe
|
|
140
|
-
const before = safe
|
|
141
|
-
const ch = safe
|
|
142
|
-
const after = safe
|
|
140
|
+
const pos = Math.max(0, Math.min(cursor, gLength(safe)));
|
|
141
|
+
const before = gSlice(safe, 0, pos);
|
|
142
|
+
const ch = gSlice(safe, pos, pos + 1) || ' ';
|
|
143
|
+
const after = gSlice(safe, pos + 1);
|
|
143
144
|
return (_jsxs(Text, { wrap: "wrap", children: [before, _jsx(Text, { inverse: true, children: ch }), after] }));
|
|
144
145
|
}
|
|
145
146
|
// design.md §23/§24 — terminal Markdown via tui/markdown.ts: headings,
|
|
@@ -181,6 +182,53 @@ function MarkdownText({ text, dim, width }) {
|
|
|
181
182
|
// Position is an Anchor ({itemId, lineInItem}), never a raw row index (I4),
|
|
182
183
|
// resolved per frame against measured *display lines* (I3, see measure.ts).
|
|
183
184
|
// While anchored to 'bottom', new output follows; scrolling up pins to an
|
|
185
|
+
// Display-state bound: the transcript array never grows without limit.
|
|
186
|
+
// Beyond MAX_TRANSCRIPT_ITEMS the oldest non-user items go first (tool
|
|
187
|
+
// rows, thinking, policy), then oldest-first. Pinned scroll anchors
|
|
188
|
+
// survive via PRUNE re-stick (scroll-model); persistence is unaffected.
|
|
189
|
+
const MAX_TRANSCRIPT_ITEMS = 500;
|
|
190
|
+
export { MAX_TRANSCRIPT_ITEMS as TRANSCRIPT_ITEM_CAP };
|
|
191
|
+
export function capTranscript(prev) {
|
|
192
|
+
const over = prev.length - MAX_TRANSCRIPT_ITEMS;
|
|
193
|
+
if (over <= 0)
|
|
194
|
+
return prev;
|
|
195
|
+
const isUser = (x) => x.kind === 'text' && x.role === 'user';
|
|
196
|
+
// Shed the oldest non-user items wherever they sit (a contiguous splice
|
|
197
|
+
// from the first drop point would also eat the user rows between them).
|
|
198
|
+
let toDrop = over;
|
|
199
|
+
const next = prev.filter((x) => {
|
|
200
|
+
if (toDrop > 0 && !isUser(x)) {
|
|
201
|
+
toDrop--;
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
});
|
|
206
|
+
// All-user transcript: oldest-first.
|
|
207
|
+
return next.length > MAX_TRANSCRIPT_ITEMS ? next.slice(next.length - MAX_TRANSCRIPT_ITEMS) : next;
|
|
208
|
+
}
|
|
209
|
+
// Grapheme-cluster cursor math: vimCursor/input positions are GRAPHEME
|
|
210
|
+
// indices, never UTF-16 offsets — slicing a surrogate pair or ZWJ cluster
|
|
211
|
+
// renders lone surrogates and corrupts emoji/CJK on h/l/x. Intl.Segmenter
|
|
212
|
+
// with an Array.from fallback (splits pairs correctly, not clusters).
|
|
213
|
+
let segmenterCache;
|
|
214
|
+
function graphemes(s) {
|
|
215
|
+
try {
|
|
216
|
+
if (segmenterCache === undefined) {
|
|
217
|
+
const Seg = Intl.Segmenter;
|
|
218
|
+
segmenterCache = Seg ? new Seg(undefined, { granularity: 'grapheme' }) : null;
|
|
219
|
+
}
|
|
220
|
+
if (segmenterCache)
|
|
221
|
+
return [...segmenterCache.segment(s)].map((x) => x.segment);
|
|
222
|
+
}
|
|
223
|
+
catch { /* fall through to code points */ }
|
|
224
|
+
return Array.from(s);
|
|
225
|
+
}
|
|
226
|
+
function gLength(s) {
|
|
227
|
+
return graphemes(s).length;
|
|
228
|
+
}
|
|
229
|
+
function gSlice(s, start, end) {
|
|
230
|
+
return graphemes(s).slice(start, end).join('');
|
|
231
|
+
}
|
|
184
232
|
// item and freezes, accumulating newSinceUnstick lines for the `↓ N new` pill.
|
|
185
233
|
//
|
|
186
234
|
// Deviation from §7.1: Ink cannot overlay rows, so the badge renders as a
|
|
@@ -201,6 +249,8 @@ function useChatScroll(opts) {
|
|
|
201
249
|
ctxRef.current = ctx;
|
|
202
250
|
// Auto-follow wiring (§6): measured total deltas → CONTENT_GREW;
|
|
203
251
|
// width change → REFLOW (§12). Initial anchor is 'bottom' → follow-tail.
|
|
252
|
+
// A pinned anchor whose item vanished (transcript pruning) re-sticks via
|
|
253
|
+
// PRUNE with a zeroed badge counter instead of inheriting stale counts.
|
|
204
254
|
const prevTotalRef = useRef(index.total);
|
|
205
255
|
const prevWidthRef = useRef(width);
|
|
206
256
|
useEffect(() => {
|
|
@@ -208,6 +258,11 @@ function useChatScroll(opts) {
|
|
|
208
258
|
const prevWidth = prevWidthRef.current;
|
|
209
259
|
prevTotalRef.current = index.total;
|
|
210
260
|
prevWidthRef.current = width;
|
|
261
|
+
const st = stateRef.current;
|
|
262
|
+
if (st.anchor.mode === 'pinned' && !keys.includes(st.anchor.itemId)) {
|
|
263
|
+
setState((s) => scrollReducer(s, { type: 'PRUNE' }, ctxRef.current));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
211
266
|
if (width !== prevWidth) {
|
|
212
267
|
setState((s) => scrollReducer(s, { type: 'REFLOW' }, ctxRef.current));
|
|
213
268
|
return;
|
|
@@ -330,16 +385,17 @@ export function App(props) {
|
|
|
330
385
|
const vimCursorRef = useRef(null);
|
|
331
386
|
useEffect(() => { inputRef.current = input; }, [input]);
|
|
332
387
|
useEffect(() => { vimCursorRef.current = vimCursor; }, [vimCursor]);
|
|
333
|
-
const clampCursor = (v, c) => c === null ? v
|
|
388
|
+
const clampCursor = (v, c) => c === null ? gLength(v) : Math.max(0, Math.min(c, gLength(v)));
|
|
334
389
|
const insertAtCursor = useCallback((text) => {
|
|
335
390
|
const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
336
391
|
if (!t)
|
|
337
392
|
return;
|
|
338
393
|
const v = inputRef.current;
|
|
339
394
|
const pos = clampCursor(v, vimCursorRef.current);
|
|
340
|
-
const nv = v
|
|
395
|
+
const nv = gSlice(v, 0, pos) + t + gSlice(v, pos);
|
|
341
396
|
setInput(nv);
|
|
342
|
-
|
|
397
|
+
const npos = pos + gLength(t);
|
|
398
|
+
setVimCursor(npos >= gLength(nv) ? null : npos);
|
|
343
399
|
setHistIdx(null);
|
|
344
400
|
}, []);
|
|
345
401
|
const deleteBeforeCursor = useCallback(() => {
|
|
@@ -347,9 +403,9 @@ export function App(props) {
|
|
|
347
403
|
const pos = clampCursor(v, vimCursorRef.current);
|
|
348
404
|
if (pos === 0)
|
|
349
405
|
return;
|
|
350
|
-
const nv = v
|
|
406
|
+
const nv = gSlice(v, 0, pos - 1) + gSlice(v, pos);
|
|
351
407
|
setInput(nv);
|
|
352
|
-
setVimCursor(pos - 1 >= nv
|
|
408
|
+
setVimCursor(pos - 1 >= gLength(nv) ? null : pos - 1);
|
|
353
409
|
setHistIdx(null);
|
|
354
410
|
}, []);
|
|
355
411
|
// Shift+Enter intent: explicit shift+return, kitty/CSI-u sequence, legacy
|
|
@@ -603,7 +659,7 @@ export function App(props) {
|
|
|
603
659
|
if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
|
|
604
660
|
const toSend = queuedInputs[0];
|
|
605
661
|
setQueuedInputs((prev) => prev.slice(1));
|
|
606
|
-
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: toSend, role: 'user' }]);
|
|
662
|
+
setTranscript((prev) => [...capTranscript(prev), { id: nextId('user'), kind: 'text', text: toSend, role: 'user' }]);
|
|
607
663
|
resetStream();
|
|
608
664
|
const cmd = parseSlash(toSend.trim());
|
|
609
665
|
if (cmd.kind === 'prompt')
|
|
@@ -619,7 +675,7 @@ export function App(props) {
|
|
|
619
675
|
flushStream();
|
|
620
676
|
resetStream();
|
|
621
677
|
}
|
|
622
|
-
setTranscript((prev) => [...prev, item]);
|
|
678
|
+
setTranscript((prev) => [...capTranscript(prev), item]);
|
|
623
679
|
}, [flushStream, resetStream]);
|
|
624
680
|
const appendDelta = useCallback((text) => {
|
|
625
681
|
if (!text)
|
|
@@ -797,7 +853,7 @@ export function App(props) {
|
|
|
797
853
|
// preserving the message prefix (`look at @ut` → `look at @util.ts`).
|
|
798
854
|
const m = /(?:^|\s)@(\S*)$/.exec(input);
|
|
799
855
|
if (m && m[1] !== undefined) {
|
|
800
|
-
setInput(input
|
|
856
|
+
setInput(gSlice(input, 0, gLength(input) - gLength(m[1])) + top.name.slice(1) + ' ');
|
|
801
857
|
}
|
|
802
858
|
else {
|
|
803
859
|
setInput(`${top.name} `);
|
|
@@ -898,8 +954,26 @@ export function App(props) {
|
|
|
898
954
|
return;
|
|
899
955
|
}
|
|
900
956
|
}
|
|
901
|
-
|
|
957
|
+
// Approval gate: keys must never leak into the input while a prompt is
|
|
958
|
+
// pending. Decisive keys are routed straight through the bridge instead
|
|
959
|
+
// of being swallowed: the modal subscribes async, so a fast y/n landing
|
|
960
|
+
// between ask() and the modal's first render used to vanish and hang
|
|
961
|
+
// the run on a visibly-pending modal. bridge.resolve() is a no-op when
|
|
962
|
+
// nothing is pending, and the modal's own handler ignores the duplicate
|
|
963
|
+
// via the same guard — either order is safe. 'e' is deliberately NOT
|
|
964
|
+
// routed (it enters the modal's inline edit mode, not a decision).
|
|
965
|
+
if (awaitingApproval) {
|
|
966
|
+
if (key.return) {
|
|
967
|
+
bridge.resolve('deny');
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const choice = approvalChoiceForKey(inputStr);
|
|
971
|
+
if ((choice === 'allow' || choice === 'always' || choice === 'always-persist' || choice === 'deny') &&
|
|
972
|
+
inputStr !== 'e') {
|
|
973
|
+
bridge.resolve(choice);
|
|
974
|
+
}
|
|
902
975
|
return;
|
|
976
|
+
}
|
|
903
977
|
if (key.ctrl && inputStr === 'o') {
|
|
904
978
|
const groups = grouped.filter((x) => typeof x.verb === 'string');
|
|
905
979
|
const last = groups[groups.length - 1];
|
|
@@ -932,46 +1006,67 @@ export function App(props) {
|
|
|
932
1006
|
setVimMode('insert');
|
|
933
1007
|
return;
|
|
934
1008
|
}
|
|
935
|
-
|
|
936
|
-
|
|
1009
|
+
// All positions below are grapheme indices (see graphemes()): cursor
|
|
1010
|
+
// math, word motions, and deletions never split a cluster.
|
|
1011
|
+
const gs = graphemes(input);
|
|
1012
|
+
const cur = vimCursor === null ? gs.length : Math.max(0, Math.min(vimCursor, gs.length));
|
|
1013
|
+
const move = (d) => { setVimCursor(Math.max(0, Math.min(cur + d, gs.length))); return true; };
|
|
937
1014
|
const isWord = (c) => /[A-Za-z0-9_]/.test(c);
|
|
938
1015
|
const wordFwd = (pos, n) => {
|
|
939
1016
|
let p = pos;
|
|
940
|
-
for (let k = 0; k < n && p <
|
|
941
|
-
while (p <
|
|
1017
|
+
for (let k = 0; k < n && p < gs.length; k++) {
|
|
1018
|
+
while (p < gs.length && isWord(gs[p]))
|
|
942
1019
|
p++;
|
|
943
|
-
while (p <
|
|
1020
|
+
while (p < gs.length && !isWord(gs[p]))
|
|
944
1021
|
p++;
|
|
945
1022
|
}
|
|
946
|
-
return Math.min(p,
|
|
1023
|
+
return Math.min(p, gs.length);
|
|
947
1024
|
};
|
|
948
1025
|
const wordBack = (pos, n) => {
|
|
949
1026
|
let p = pos;
|
|
950
1027
|
for (let k = 0; k < n && p > 0; k++) {
|
|
951
|
-
while (p > 0 && !isWord(
|
|
1028
|
+
while (p > 0 && !isWord(gs[p - 1]))
|
|
952
1029
|
p--;
|
|
953
|
-
while (p > 0 && isWord(
|
|
1030
|
+
while (p > 0 && isWord(gs[p - 1]))
|
|
954
1031
|
p--;
|
|
955
1032
|
}
|
|
956
1033
|
return Math.max(0, p);
|
|
957
1034
|
};
|
|
1035
|
+
const lineStart = (pos) => {
|
|
1036
|
+
for (let i = pos - 1; i >= 0; i--) {
|
|
1037
|
+
if (gs[i] === '\n')
|
|
1038
|
+
return i + 1;
|
|
1039
|
+
}
|
|
1040
|
+
return 0;
|
|
1041
|
+
};
|
|
1042
|
+
const endOfNthLine = (from, n, keepNewline) => {
|
|
1043
|
+
let seen = 0;
|
|
1044
|
+
for (let i = from; i < gs.length; i++) {
|
|
1045
|
+
if (gs[i] === '\n') {
|
|
1046
|
+
seen++;
|
|
1047
|
+
if (seen === n)
|
|
1048
|
+
return keepNewline ? i : i + 1;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return gs.length;
|
|
1052
|
+
};
|
|
958
1053
|
const takeCount = () => {
|
|
959
1054
|
const n = vimCountRef.current <= 1 ? 1 : vimCountRef.current;
|
|
960
1055
|
vimCountRef.current = 0;
|
|
961
1056
|
return n;
|
|
962
1057
|
};
|
|
963
1058
|
if (!key.ctrl && !key.meta && !key.return && !key.tab && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) {
|
|
964
|
-
// Pending `d` operator: second `d` deletes
|
|
1059
|
+
// Pending `d` operator: second `d` deletes whole lines (count-aware).
|
|
965
1060
|
if (vimPendingOp.current === 'd') {
|
|
966
1061
|
vimPendingOp.current = null;
|
|
967
1062
|
if (inputStr === 'd') {
|
|
968
|
-
const
|
|
969
|
-
const
|
|
970
|
-
const end =
|
|
971
|
-
|
|
972
|
-
|
|
1063
|
+
const n = takeCount();
|
|
1064
|
+
const start = lineStart(cur);
|
|
1065
|
+
const end = endOfNthLine(start, n, false);
|
|
1066
|
+
const nv = gs.slice(0, start).join('') + gs.slice(end).join('');
|
|
1067
|
+
setInput(nv);
|
|
1068
|
+
setVimCursor(Math.min(start, gLength(nv)) >= gLength(nv) ? null : Math.min(start, gLength(nv)));
|
|
973
1069
|
setHistIdx(null);
|
|
974
|
-
vimCountRef.current = 0;
|
|
975
1070
|
return;
|
|
976
1071
|
}
|
|
977
1072
|
// Only dd is supported — anything else cancels the operator.
|
|
@@ -984,7 +1079,7 @@ export function App(props) {
|
|
|
984
1079
|
return;
|
|
985
1080
|
}
|
|
986
1081
|
if (inputStr === 'a') {
|
|
987
|
-
setVimCursor(Math.min(cur + 1,
|
|
1082
|
+
setVimCursor(Math.min(cur + 1, gs.length));
|
|
988
1083
|
setVimMode('insert');
|
|
989
1084
|
vimCountRef.current = 0;
|
|
990
1085
|
return;
|
|
@@ -1003,24 +1098,24 @@ export function App(props) {
|
|
|
1003
1098
|
move(n);
|
|
1004
1099
|
return;
|
|
1005
1100
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1101
|
+
// `0` alone goes to line start; after digits it extends the count
|
|
1102
|
+
// (`10` + `0` = 100, matching vim — the old code moved left by N).
|
|
1010
1103
|
if (inputStr === '0') {
|
|
1011
|
-
|
|
1012
|
-
|
|
1104
|
+
if (vimCountRef.current === 0)
|
|
1105
|
+
setVimCursor(lineStart(cur));
|
|
1106
|
+
else
|
|
1107
|
+
vimCountRef.current = vimCountRef.current * 10;
|
|
1013
1108
|
return;
|
|
1014
1109
|
}
|
|
1015
1110
|
if (inputStr === '$') {
|
|
1016
1111
|
takeCount();
|
|
1017
|
-
setVimCursor(
|
|
1112
|
+
setVimCursor(gs.length);
|
|
1018
1113
|
return;
|
|
1019
1114
|
}
|
|
1020
1115
|
if (inputStr === 'x') {
|
|
1021
1116
|
const n = takeCount();
|
|
1022
|
-
if (cur <
|
|
1023
|
-
setInput(input
|
|
1117
|
+
if (cur < gs.length)
|
|
1118
|
+
setInput(gSlice(input, 0, cur) + gSlice(input, cur + n));
|
|
1024
1119
|
setHistIdx(null);
|
|
1025
1120
|
return;
|
|
1026
1121
|
}
|
|
@@ -1040,24 +1135,25 @@ export function App(props) {
|
|
|
1040
1135
|
return;
|
|
1041
1136
|
}
|
|
1042
1137
|
if (inputStr === 'D') {
|
|
1043
|
-
// Delete to end of
|
|
1044
|
-
const
|
|
1045
|
-
const end =
|
|
1046
|
-
setInput(input
|
|
1138
|
+
// Delete to end of line, count-aware (2D spans two lines).
|
|
1139
|
+
const n = takeCount();
|
|
1140
|
+
const end = endOfNthLine(cur, n, true);
|
|
1141
|
+
setInput(gSlice(input, 0, cur) + gSlice(input, end));
|
|
1047
1142
|
setHistIdx(null);
|
|
1048
|
-
vimCountRef.current = 0;
|
|
1049
1143
|
return;
|
|
1050
1144
|
}
|
|
1051
1145
|
if (inputStr === 'j') {
|
|
1146
|
+
const n = takeCount();
|
|
1052
1147
|
if (isFullscreen && maxTop > 0)
|
|
1053
|
-
|
|
1054
|
-
|
|
1148
|
+
for (let i = 0; i < n; i++)
|
|
1149
|
+
commands.lineDown();
|
|
1055
1150
|
return;
|
|
1056
1151
|
}
|
|
1057
1152
|
if (inputStr === 'k') {
|
|
1153
|
+
const n = takeCount();
|
|
1058
1154
|
if (isFullscreen && maxTop > 0)
|
|
1059
|
-
|
|
1060
|
-
|
|
1155
|
+
for (let i = 0; i < n; i++)
|
|
1156
|
+
commands.lineUp();
|
|
1061
1157
|
return;
|
|
1062
1158
|
}
|
|
1063
1159
|
if (key.backspace || key.delete) {
|
|
@@ -1114,6 +1210,11 @@ export function App(props) {
|
|
|
1114
1210
|
disarmEsc();
|
|
1115
1211
|
return;
|
|
1116
1212
|
}
|
|
1213
|
+
// This Esc is a cancel gesture, not half of an ESC+CR newline pair:
|
|
1214
|
+
// disarm the 75ms Esc→Return heuristic so a fast follow-up Return
|
|
1215
|
+
// submits instead of inserting a newline. (The vim-normal-mode
|
|
1216
|
+
// partner below is intentionally untouched — it owns that meaning.)
|
|
1217
|
+
escReturnAt.current = 0;
|
|
1117
1218
|
const t = Date.now();
|
|
1118
1219
|
if (t - escArmedAt.current < 1500) {
|
|
1119
1220
|
disarmEsc();
|
|
@@ -1201,7 +1302,7 @@ export function App(props) {
|
|
|
1201
1302
|
setVimCursor(null);
|
|
1202
1303
|
setHistIdx(null);
|
|
1203
1304
|
pushHistory(v);
|
|
1204
|
-
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
1305
|
+
setTranscript((prev) => [...capTranscript(prev), { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
1205
1306
|
resetStream();
|
|
1206
1307
|
setSubmitKey((k) => k + 1);
|
|
1207
1308
|
const cmd = parseSlash(v);
|
|
@@ -1221,15 +1322,19 @@ export function App(props) {
|
|
|
1221
1322
|
// Idle Esc enters vim normal mode (vimLive parity for keyboard-only users).
|
|
1222
1323
|
if (key.escape && vimMode === 'insert') {
|
|
1223
1324
|
setVimMode('normal');
|
|
1224
|
-
setVimCursor(inputRef.current
|
|
1325
|
+
setVimCursor(gLength(inputRef.current));
|
|
1225
1326
|
return;
|
|
1226
1327
|
}
|
|
1227
1328
|
});
|
|
1228
1329
|
// Single source of truth: package.json via version.ts — never hardcoded.
|
|
1229
1330
|
const ver = props.version ?? readVersion();
|
|
1230
1331
|
// Single cost/context source: model-aware registry rates + window.
|
|
1231
|
-
|
|
1232
|
-
const
|
|
1332
|
+
// Cache counters included: Anthropic bills them and they consume context.
|
|
1333
|
+
const cost = estimateCost(status.model, status.usageInput, status.usageOutput, {
|
|
1334
|
+
read: status.usageCacheRead,
|
|
1335
|
+
write: status.usageCacheWrite,
|
|
1336
|
+
});
|
|
1337
|
+
const totalTokens = status.usageInput + status.usageOutput + (status.usageCacheRead ?? 0) + (status.usageCacheWrite ?? 0);
|
|
1233
1338
|
const ctxWindow = getModelInfo(status.model).contextWindow;
|
|
1234
1339
|
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
|
|
1235
1340
|
// Narrow terminals: compact hints so the status bar never wraps mid-word.
|
package/dist/tui/app.test.js
CHANGED
|
@@ -676,6 +676,27 @@ describe('App', () => {
|
|
|
676
676
|
stdin.write('d');
|
|
677
677
|
await waitForMatch(lastFrame, /Message Klyro/);
|
|
678
678
|
});
|
|
679
|
+
it('vim motions never split emoji graphemes', async () => {
|
|
680
|
+
let hooks = null;
|
|
681
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
|
|
682
|
+
hooks = { setVimMode: h.setVimMode };
|
|
683
|
+
} }));
|
|
684
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
685
|
+
stdin.write('a🎉b');
|
|
686
|
+
await waitForMatch(lastFrame, /a🎉b/);
|
|
687
|
+
hooks.setVimMode('normal');
|
|
688
|
+
await waitForMatch(lastFrame, /--NORMAL--/);
|
|
689
|
+
// h/l move whole graphemes; x deletes the whole emoji, never a half.
|
|
690
|
+
stdin.write('0');
|
|
691
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
692
|
+
stdin.write('l');
|
|
693
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
694
|
+
stdin.write('x');
|
|
695
|
+
await waitForMatch(lastFrame, /ab/);
|
|
696
|
+
const frame = lastFrame() ?? '';
|
|
697
|
+
expect(frame).not.toMatch(/🎉/);
|
|
698
|
+
expect(frame).not.toMatch(/\uFFFD/); // no replacement char from split surrogates
|
|
699
|
+
});
|
|
679
700
|
it('vim normal mode swallows typing, shows mode, i returns to insert', async () => {
|
|
680
701
|
let hooks = null;
|
|
681
702
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
|