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.
@@ -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
- const timer = setTimeout(() => { try {
8
- child.kill('SIGKILL');
9
- }
10
- catch { /* noop */ } }, timeoutMs);
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
- try {
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
- const proc = spawn(command, { cwd, shell: true, windowsHide: true, env: filteredEnv() });
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
- proc.on('close', () => {
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
- proc.on('close', () => {
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
- try {
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
- try {
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
- const cwd = input.cwd ?? ctx.cwd;
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.length));
140
- const before = safe.slice(0, pos);
141
- const ch = safe.slice(pos, pos + 1) || ' ';
142
- const after = safe.slice(pos + 1);
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.length : Math.max(0, Math.min(c, v.length));
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.slice(0, pos) + t + v.slice(pos);
395
+ const nv = gSlice(v, 0, pos) + t + gSlice(v, pos);
341
396
  setInput(nv);
342
- setVimCursor(pos + t.length >= nv.length ? null : pos + t.length);
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.slice(0, pos - 1) + v.slice(pos);
406
+ const nv = gSlice(v, 0, pos - 1) + gSlice(v, pos);
351
407
  setInput(nv);
352
- setVimCursor(pos - 1 >= nv.length ? null : pos - 1);
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.slice(0, input.length - m[1].length) + top.name.slice(1) + ' ');
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
- if (awaitingApproval)
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
- const cur = vimCursor === null ? input.length : Math.max(0, Math.min(vimCursor, input.length));
936
- const move = (d) => { setVimCursor(Math.max(0, Math.min(cur + d, input.length))); return true; };
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 < input.length; k++) {
941
- while (p < input.length && isWord(input[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 < input.length && !isWord(input[p]))
1020
+ while (p < gs.length && !isWord(gs[p]))
944
1021
  p++;
945
1022
  }
946
- return Math.min(p, input.length);
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(input[p - 1]))
1028
+ while (p > 0 && !isWord(gs[p - 1]))
952
1029
  p--;
953
- while (p > 0 && isWord(input[p - 1]))
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 the current line.
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 start = input.lastIndexOf('\n', cur - 1) + 1;
969
- const nl = input.indexOf('\n', cur);
970
- const end = nl === -1 ? input.length : nl + 1;
971
- setInput(input.slice(0, start) + input.slice(end));
972
- setVimCursor(Math.min(start, Math.max(0, input.slice(0, start).length)));
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, input.length));
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
- if (inputStr === '0' && vimCountRef.current === 0) {
1007
- setVimCursor(0);
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
- const n = takeCount();
1012
- move(-n);
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(input.length);
1112
+ setVimCursor(gs.length);
1018
1113
  return;
1019
1114
  }
1020
1115
  if (inputStr === 'x') {
1021
1116
  const n = takeCount();
1022
- if (cur < input.length)
1023
- setInput(input.slice(0, cur) + input.slice(cur + n));
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 current line (keep the newline).
1044
- const nl = input.indexOf('\n', cur);
1045
- const end = nl === -1 ? input.length : nl;
1046
- setInput(input.slice(0, cur) + input.slice(end));
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
- commands.lineDown();
1054
- vimCountRef.current = 0;
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
- commands.lineUp();
1060
- vimCountRef.current = 0;
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.length);
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
- const cost = estimateCost(status.model, status.usageInput, status.usageOutput);
1232
- const totalTokens = status.usageInput + status.usageOutput;
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.
@@ -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) => {