klyro 1.0.18 → 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 +15 -6
- package/dist/agent/worktree-manager.d.ts +2 -0
- package/dist/agent/worktree-manager.js +9 -2
- package/dist/checkpoints/store.d.ts +13 -0
- package/dist/checkpoints/store.js +65 -2
- package/dist/cli/dotenv.js +1 -1
- package/dist/cli/repl.js +22 -5
- 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/lsp/diagnostics.d.ts +50 -0
- package/dist/tools/lsp/diagnostics.js +109 -3
- package/dist/tools/registry.js +3 -1
- 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
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) => {
|
package/dist/tui/diff-parser.js
CHANGED
|
@@ -17,22 +17,45 @@
|
|
|
17
17
|
export function parseUnifiedDiff(raw) {
|
|
18
18
|
const out = [];
|
|
19
19
|
let current = null;
|
|
20
|
+
// Candidate path from the `--- ` side: used when the `+++ ` side is
|
|
21
|
+
// /dev/null (deleted file) and therefore names no real path.
|
|
22
|
+
let fromPath = null;
|
|
20
23
|
for (const line of raw.split('\n')) {
|
|
21
24
|
if (line.startsWith('diff --git ')) {
|
|
22
25
|
if (current)
|
|
23
26
|
out.push(current);
|
|
24
27
|
current = null;
|
|
28
|
+
fromPath = null;
|
|
25
29
|
continue;
|
|
26
30
|
}
|
|
27
31
|
if (line.startsWith('+++ ')) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const toRaw = line.slice(4).trim();
|
|
33
|
+
// A new file section without a `diff --git` header must not swallow
|
|
34
|
+
// the previous file's lines — flush first.
|
|
35
|
+
if (current && current.lines.length > 0) {
|
|
36
|
+
out.push(current);
|
|
37
|
+
current = null;
|
|
38
|
+
}
|
|
39
|
+
const toPath = stripDiffPrefix(toRaw);
|
|
40
|
+
if (toPath && toPath !== '/dev/null') {
|
|
41
|
+
current = { path: toPath, lines: [] };
|
|
42
|
+
}
|
|
43
|
+
else if (fromPath && fromPath !== '/dev/null') {
|
|
44
|
+
// Deleted file: `--- a/f` / `+++ /dev/null` — keep the real path,
|
|
45
|
+
// not `/dev/null`.
|
|
46
|
+
current = { path: fromPath, lines: [] };
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
current = null;
|
|
50
|
+
}
|
|
51
|
+
fromPath = null;
|
|
31
52
|
continue;
|
|
32
53
|
}
|
|
33
54
|
if (line.startsWith('--- ')) {
|
|
34
|
-
//
|
|
35
|
-
//
|
|
55
|
+
// Stash the "from" path (see `+++ ` handling above) — we still
|
|
56
|
+
// prefer the "to" path so the heading matches the edited file.
|
|
57
|
+
const cand = stripDiffPrefix(line.slice(4).trim());
|
|
58
|
+
fromPath = cand || null;
|
|
36
59
|
continue;
|
|
37
60
|
}
|
|
38
61
|
if (line.startsWith('@@')) {
|
package/dist/tui/markdown.js
CHANGED
|
@@ -146,7 +146,7 @@ export function highlightCodeLine(line, lang) {
|
|
|
146
146
|
return parts;
|
|
147
147
|
}
|
|
148
148
|
/** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
|
|
149
|
-
export const FILE_LINE_RE = /((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
|
|
149
|
+
export const FILE_LINE_RE = /(?<![:/\w])((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
|
|
150
150
|
export function annotateFileLinks(parts) {
|
|
151
151
|
const out = [];
|
|
152
152
|
for (const p of parts) {
|
package/dist/tui/measure.js
CHANGED
|
@@ -85,8 +85,11 @@ export function blockHeight(b, termWidth) {
|
|
|
85
85
|
return 1 + 1;
|
|
86
86
|
case 'diff': {
|
|
87
87
|
let n = 1; // summary
|
|
88
|
+
// Render prepends a 2-col `+ `/`- `/` ` prefix per line (app.tsx),
|
|
89
|
+
// so measure against cw-2 — a bare-text wrap at full cw under-counts
|
|
90
|
+
// borderline lines by a row each and drifts the scroll anchor.
|
|
88
91
|
for (const h of b.hunks)
|
|
89
|
-
n += 1 + h.lines.reduce((s, l) => s + wrapCount(l, cw), 0);
|
|
92
|
+
n += 1 + h.lines.reduce((s, l) => s + wrapCount(l, cw - 2), 0);
|
|
90
93
|
return n + 1;
|
|
91
94
|
}
|
|
92
95
|
case 'plan':
|
package/dist/tui/scroll-model.js
CHANGED
|
@@ -65,6 +65,11 @@ export function scrollReducer(s, a, ctx) {
|
|
|
65
65
|
}
|
|
66
66
|
void maxTop;
|
|
67
67
|
return { ...s, newSinceUnstick: s.newSinceUnstick + a.lines };
|
|
68
|
+
case 'PRUNE':
|
|
69
|
+
// Anchored item was compacted away: re-stick to bottom with a clean
|
|
70
|
+
// counter. Without the reset, a stale newSinceUnstick inflates the
|
|
71
|
+
// `↓ N new` badge after the re-stick.
|
|
72
|
+
return stickBottom();
|
|
68
73
|
case 'REFLOW':
|
|
69
74
|
return { ...s };
|
|
70
75
|
}
|
package/dist/tui/status.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export interface StatusSnapshot {
|
|
|
11
11
|
maxSteps: number;
|
|
12
12
|
usageInput: number;
|
|
13
13
|
usageOutput: number;
|
|
14
|
+
/** Prompt-cache counters (Anthropic-family): billed and context-consuming. */
|
|
15
|
+
usageCacheRead?: number;
|
|
16
|
+
usageCacheWrite?: number;
|
|
14
17
|
repairs: number;
|
|
15
18
|
status: 'idle' | 'running' | 'done' | 'error' | 'aborted';
|
|
16
19
|
errorMessage?: string;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* On failure: returns { ok: false, failure } with the most recent
|
|
8
8
|
* structured Failure (and the model has had up to N attempts to fix).
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from 'node:child_process';
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
11
|
import { detect, summarize } from './detect.js';
|
|
12
12
|
import { filteredVerifyEnv } from './registry.js';
|
|
13
13
|
import { redact } from '../policy/secret-redactor.js';
|
|
@@ -84,10 +84,26 @@ export async function verify(opts) {
|
|
|
84
84
|
const outCap = cappedOutput(MAX_VERIFY_BYTES);
|
|
85
85
|
const errCap = cappedOutput(MAX_VERIFY_BYTES);
|
|
86
86
|
let done = false;
|
|
87
|
+
const killTree = () => {
|
|
88
|
+
// Match run_verify/shell_exec: tree-kill so grandchildren die too.
|
|
89
|
+
// child.kill() alone only takes the /bin/sh (or cmd) wrapper.
|
|
90
|
+
if (process.platform === 'win32') {
|
|
91
|
+
try {
|
|
92
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
|
|
93
|
+
}
|
|
94
|
+
catch { /* already gone */ }
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
try {
|
|
98
|
+
child.kill('SIGKILL');
|
|
99
|
+
}
|
|
100
|
+
catch { /* noop */ }
|
|
101
|
+
}
|
|
102
|
+
};
|
|
87
103
|
const timer = setTimeout(() => {
|
|
88
104
|
if (done)
|
|
89
105
|
return;
|
|
90
|
-
|
|
106
|
+
killTree();
|
|
91
107
|
done = true;
|
|
92
108
|
const so = outCap.text();
|
|
93
109
|
const se = errCap.text();
|
|
@@ -103,6 +119,23 @@ export async function verify(opts) {
|
|
|
103
119
|
}, timeout);
|
|
104
120
|
child.stdout.on('data', (b) => outCap.push(b));
|
|
105
121
|
child.stderr.on('data', (b) => errCap.push(b));
|
|
122
|
+
// A bad cwd (or missing shell) emits 'error' with no 'close' — without
|
|
123
|
+
// this the promise never settles and the run hangs to its own timeout.
|
|
124
|
+
child.on('error', (err) => {
|
|
125
|
+
if (done)
|
|
126
|
+
return;
|
|
127
|
+
done = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
const raw = `verify spawn failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
130
|
+
emitFailed('runtime');
|
|
131
|
+
resolve({
|
|
132
|
+
ok: false,
|
|
133
|
+
exitCode: -1,
|
|
134
|
+
stdout: outCap.text(),
|
|
135
|
+
stderr: raw,
|
|
136
|
+
failure: { type: 'runtime', files: [], raw, exitCode: -1 },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
106
139
|
child.on('close', (code) => {
|
|
107
140
|
if (done)
|
|
108
141
|
return;
|
|
@@ -39,13 +39,25 @@ export function findRelatedTests(cwd, editedFiles) {
|
|
|
39
39
|
const dir = path.dirname(edited);
|
|
40
40
|
for (const t of allTests) {
|
|
41
41
|
const tb = path.basename(t);
|
|
42
|
+
const stripped = path.basename(t, path.extname(t)).replace(/\.test|\.spec|test_/g, '');
|
|
42
43
|
// name match: foo.ts -> foo.test.ts, test_foo.py, foo_spec.ts, etc.
|
|
43
|
-
|
|
44
|
+
// Substring arms need a substantive core (>=4 chars): without the
|
|
45
|
+
// floor, `a.ts` matches nearly every test basename globally, and
|
|
46
|
+
// `catalog.ts` matches unrelated `cat.test.ts`. Exact stem equality
|
|
47
|
+
// always counts (covers single-char stems like `a.ts` -> `a.test.ts`).
|
|
48
|
+
if (stripped === base ||
|
|
49
|
+
(base.length >= 4 && tb.includes(base)) ||
|
|
50
|
+
(stripped.length >= 4 && base.includes(stripped))) {
|
|
44
51
|
related.add(path.relative(cwd, t));
|
|
45
52
|
}
|
|
46
|
-
// same
|
|
47
|
-
|
|
53
|
+
// same-directory similarity (not a raw string prefix: `/repo/foo2/…`
|
|
54
|
+
// must not match edited dir `/repo/foo`). The 4-char slice needs a
|
|
55
|
+
// real core name behind it, same floor as above.
|
|
56
|
+
const rel = path.relative(dir, t);
|
|
57
|
+
const inDir = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
58
|
+
if (inDir && base.length >= 4 && tb.toLowerCase().includes(base.slice(0, 4).toLowerCase())) {
|
|
48
59
|
related.add(path.relative(cwd, t));
|
|
60
|
+
}
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
return [...related];
|
|
@@ -222,6 +234,7 @@ export async function syntaxCheck(cwd, file) {
|
|
|
222
234
|
}
|
|
223
235
|
}
|
|
224
236
|
if (ext === '.py') {
|
|
237
|
+
let spawnFailed = false;
|
|
225
238
|
const ok = await new Promise((resolve) => {
|
|
226
239
|
// S2: verify commands run with filtered env; servers needing keys must use explicit config.
|
|
227
240
|
const child = spawn('python', ['-m', 'py_compile', full], { cwd, shell: false, env: filteredVerifyEnv() });
|
|
@@ -236,11 +249,13 @@ export async function syntaxCheck(cwd, file) {
|
|
|
236
249
|
} }, 5000);
|
|
237
250
|
child.on('close', (code) => { if (done)
|
|
238
251
|
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
252
|
+
// A missing interpreter must not read as "syntax OK" — that false
|
|
253
|
+
// negative lets broken files pass scoped verification.
|
|
239
254
|
child.on('error', () => { if (done)
|
|
240
|
-
return; done = true; clearTimeout(t); resolve(
|
|
255
|
+
return; done = true; clearTimeout(t); spawnFailed = true; resolve(false); });
|
|
241
256
|
});
|
|
242
257
|
if (!ok)
|
|
243
|
-
return { ok: false, error: `syntax error in ${file} (py_compile)` };
|
|
258
|
+
return { ok: false, error: spawnFailed ? `python unavailable for py_compile (${file})` : `syntax error in ${file} (py_compile)` };
|
|
244
259
|
}
|
|
245
260
|
return { ok: true };
|
|
246
261
|
}
|