klyro 1.0.5 → 1.0.6

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 (47) hide show
  1. package/dist/agent/custom-agents.d.ts +3 -0
  2. package/dist/agent/custom-agents.js +96 -0
  3. package/dist/agent/orchestrator.d.ts +22 -2
  4. package/dist/agent/orchestrator.js +30 -4
  5. package/dist/agent/runtime.d.ts +5 -0
  6. package/dist/agent/runtime.js +164 -50
  7. package/dist/checkpoints/store.d.ts +9 -0
  8. package/dist/checkpoints/store.js +20 -0
  9. package/dist/cli/completion.js +2 -2
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/eval.d.ts +15 -1
  12. package/dist/cli/eval.js +34 -2
  13. package/dist/cli/hooks.d.ts +54 -5
  14. package/dist/cli/hooks.js +85 -6
  15. package/dist/cli/init.d.ts +6 -0
  16. package/dist/cli/init.js +60 -0
  17. package/dist/cli/repl.js +146 -25
  18. package/dist/cli/run.d.ts +7 -1
  19. package/dist/cli/run.js +92 -50
  20. package/dist/cli/slash/custom.d.ts +25 -0
  21. package/dist/cli/slash/custom.js +166 -0
  22. package/dist/cli/slash/parser.d.ts +9 -1
  23. package/dist/cli/slash/parser.js +31 -9
  24. package/dist/context/memory.js +18 -1
  25. package/dist/eval/harness.d.ts +21 -3
  26. package/dist/eval/harness.js +31 -3
  27. package/dist/eval/judge.d.ts +32 -0
  28. package/dist/eval/judge.js +63 -0
  29. package/dist/eval/tasks.js +134 -0
  30. package/dist/index.js +150 -127
  31. package/dist/mcp/client.d.ts +15 -0
  32. package/dist/mcp/client.js +42 -2
  33. package/dist/mcp/config.d.ts +3 -1
  34. package/dist/mcp/config.js +19 -1
  35. package/dist/mcp/registry.d.ts +13 -0
  36. package/dist/mcp/registry.js +41 -1
  37. package/dist/mcp/remote.d.ts +29 -0
  38. package/dist/mcp/remote.js +153 -0
  39. package/dist/policy/approval.d.ts +15 -1
  40. package/dist/policy/approval.js +8 -0
  41. package/dist/providers/endpoints.d.ts +43 -0
  42. package/dist/providers/endpoints.js +104 -0
  43. package/dist/providers.js +13 -10
  44. package/dist/tui/app.js +140 -13
  45. package/dist/tui/app.test.js +24 -0
  46. package/dist/tui/approval.js +53 -1
  47. package/package.json +1 -1
package/dist/tui/app.js CHANGED
@@ -9,6 +9,7 @@ import Spinner from 'ink-spinner';
9
9
  import { execFileSync } from 'node:child_process';
10
10
  import { TuiApprovalBridge, ApprovalModal } from './approval.js';
11
11
  import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
12
+ import { loadCustomCommands, listCompletableFiles } from '../cli/slash/custom.js';
12
13
  import { tokens, g } from './tokens.js';
13
14
  import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, badgeLabel, } from './scroll-model.js';
14
15
  import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
@@ -268,8 +269,14 @@ export function App(props) {
268
269
  // is an explicit index (null = end); rendering clamps it.
269
270
  const [vimMode, setVimMode] = useState('insert');
270
271
  const [vimCursor, setVimCursor] = useState(null);
272
+ // Count prefix (3h) and pending operator (d for dd) — refs, not state:
273
+ // they never render on their own, only through the next command.
274
+ const vimCountRef = useRef(0);
275
+ const vimPendingOp = useRef(null);
271
276
  const setVimModeLive = useCallback((m) => {
272
277
  setVimMode(m);
278
+ vimCountRef.current = 0;
279
+ vimPendingOp.current = null;
273
280
  if (m === 'insert')
274
281
  setVimCursor(null);
275
282
  }, []);
@@ -679,9 +686,32 @@ export function App(props) {
679
686
  n.delete(id);
680
687
  else
681
688
  n.add(id); return n; });
682
- // `/` autocomplete — top 6 prefix matches while completing a command name (no space yet)
689
+ // `/` autocomplete — fuzzy top 6 over builtins + custom command files
690
+ // while completing a command name (no space yet)
691
+ const customCmds = useMemo(() => {
692
+ try {
693
+ return loadCustomCommands(props.cwd).map((c) => ({ name: c.name, hint: c.description || 'custom command' }));
694
+ }
695
+ catch {
696
+ return [];
697
+ }
698
+ }, [props.cwd]);
683
699
  const slashSuggest = input.startsWith('/') && !input.slice(1).includes(' ') && !awaitingApproval
684
- ? suggestCommands(input, 6)
700
+ ? suggestCommands(input, 6, customCmds)
701
+ : [];
702
+ // `@` file completion — fuzzy over the workspace file index while typing
703
+ // a mention token (no space yet). Directories carry trailing `/`.
704
+ const fileIndex = useMemo(() => {
705
+ try {
706
+ return listCompletableFiles(props.cwd);
707
+ }
708
+ catch {
709
+ return [];
710
+ }
711
+ }, [props.cwd]);
712
+ const atToken = /^@(\S*)$/.exec(input);
713
+ const pathSuggest = atToken && !awaitingApproval
714
+ ? suggestCommands(`/${atToken[1] ?? ''}`, 6, fileIndex.map((f) => ({ name: f, hint: 'file' }))).map((d) => ({ ...d, name: `@${d.name}` }))
685
715
  : [];
686
716
  useInput((inputStr, key) => {
687
717
  if (key.escape && queuedInputs.length > 0) {
@@ -689,13 +719,19 @@ export function App(props) {
689
719
  disarmEsc();
690
720
  return;
691
721
  }
692
- // Tab completes the top slash suggestion (e.g. `/c` → `/clear `)
722
+ // Tab completes the top slash or @-path suggestion
693
723
  if ((key.tab || inputStr === '\t') && slashSuggest.length > 0) {
694
724
  const top = slashSuggest[0];
695
725
  setInput(`/${top.name} `);
696
726
  setVimCursor(null);
697
727
  return;
698
728
  }
729
+ if ((key.tab || inputStr === '\t') && pathSuggest.length > 0) {
730
+ const top = pathSuggest[0];
731
+ setInput(`${top.name} `);
732
+ setVimCursor(null);
733
+ return;
734
+ }
699
735
  // Scroll keys (work in any mode, including while running).
700
736
  // scroll.md §6 flow: Keyboard → getTranscriptCommand → handle.
701
737
  // design.md §11: PageUp/Ctrl+U half-up, PageDown/Ctrl+D half-down,
@@ -763,7 +799,8 @@ export function App(props) {
763
799
  }
764
800
  // Vim normal mode: motion/editing keys act on the input buffer instead
765
801
  // of typing. Return/arrows/tab/ctrl fall through to the shared logic
766
- // below; every other printable key is swallowed.
802
+ // below; every other printable key is swallowed. Supports counts
803
+ // (3h/2x), word motions (w/b), line ops (dd/D) — no operators/visual.
767
804
  if (vimMode === 'normal' && !awaitingApproval) {
768
805
  // Legacy Shift+Enter partner: an Esc that just switched modes followed
769
806
  // by Return within 75ms still means newline, not submit.
@@ -774,57 +811,147 @@ export function App(props) {
774
811
  }
775
812
  const cur = vimCursor === null ? input.length : Math.max(0, Math.min(vimCursor, input.length));
776
813
  const move = (d) => { setVimCursor(Math.max(0, Math.min(cur + d, input.length))); return true; };
814
+ const isWord = (c) => /[A-Za-z0-9_]/.test(c);
815
+ const wordFwd = (pos, n) => {
816
+ let p = pos;
817
+ for (let k = 0; k < n && p < input.length; k++) {
818
+ while (p < input.length && isWord(input[p]))
819
+ p++;
820
+ while (p < input.length && !isWord(input[p]))
821
+ p++;
822
+ }
823
+ return Math.min(p, input.length);
824
+ };
825
+ const wordBack = (pos, n) => {
826
+ let p = pos;
827
+ for (let k = 0; k < n && p > 0; k++) {
828
+ while (p > 0 && !isWord(input[p - 1]))
829
+ p--;
830
+ while (p > 0 && isWord(input[p - 1]))
831
+ p--;
832
+ }
833
+ return Math.max(0, p);
834
+ };
835
+ const takeCount = () => {
836
+ const n = vimCountRef.current <= 1 ? 1 : vimCountRef.current;
837
+ vimCountRef.current = 0;
838
+ return n;
839
+ };
777
840
  if (!key.ctrl && !key.meta && !key.return && !key.tab && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) {
841
+ // Pending `d` operator: second `d` deletes the current line.
842
+ if (vimPendingOp.current === 'd') {
843
+ vimPendingOp.current = null;
844
+ if (inputStr === 'd') {
845
+ const start = input.lastIndexOf('\n', cur - 1) + 1;
846
+ const nl = input.indexOf('\n', cur);
847
+ const end = nl === -1 ? input.length : nl + 1;
848
+ setInput(input.slice(0, start) + input.slice(end));
849
+ setVimCursor(Math.min(start, Math.max(0, input.slice(0, start).length)));
850
+ setHistIdx(null);
851
+ vimCountRef.current = 0;
852
+ return;
853
+ }
854
+ // Only dd is supported — anything else cancels the operator.
855
+ vimCountRef.current = 0;
856
+ }
778
857
  if (inputStr === 'i') {
779
858
  setVimMode('insert');
780
859
  setVimCursor(null);
860
+ vimCountRef.current = 0;
781
861
  return;
782
862
  }
783
863
  if (inputStr === 'a') {
784
864
  setVimCursor(Math.min(cur + 1, input.length));
785
865
  setVimMode('insert');
866
+ vimCountRef.current = 0;
867
+ return;
868
+ }
869
+ if (/^[1-9]$/.test(inputStr)) {
870
+ vimCountRef.current = vimCountRef.current * 10 + Number(inputStr);
786
871
  return;
787
872
  }
788
873
  if (inputStr === 'h') {
789
- move(-1);
874
+ const n = takeCount();
875
+ move(-n);
790
876
  return;
791
877
  }
792
878
  if (inputStr === 'l') {
793
- move(1);
879
+ const n = takeCount();
880
+ move(n);
794
881
  return;
795
882
  }
796
- if (inputStr === '0') {
883
+ if (inputStr === '0' && vimCountRef.current === 0) {
797
884
  setVimCursor(0);
798
885
  return;
799
886
  }
887
+ if (inputStr === '0') {
888
+ const n = takeCount();
889
+ move(-n);
890
+ return;
891
+ }
800
892
  if (inputStr === '$') {
893
+ takeCount();
801
894
  setVimCursor(input.length);
802
895
  return;
803
896
  }
804
897
  if (inputStr === 'x') {
898
+ const n = takeCount();
805
899
  if (cur < input.length)
806
- setInput(input.slice(0, cur) + input.slice(cur + 1));
900
+ setInput(input.slice(0, cur) + input.slice(cur + n));
901
+ setHistIdx(null);
902
+ return;
903
+ }
904
+ if (inputStr === 'w') {
905
+ const n = takeCount();
906
+ setVimCursor(wordFwd(cur, n));
907
+ return;
908
+ }
909
+ if (inputStr === 'b') {
910
+ const n = takeCount();
911
+ setVimCursor(wordBack(cur, n));
912
+ return;
913
+ }
914
+ if (inputStr === 'd') {
915
+ vimPendingOp.current = 'd';
916
+ vimCountRef.current = 0;
917
+ return;
918
+ }
919
+ if (inputStr === 'D') {
920
+ // Delete to end of current line (keep the newline).
921
+ const nl = input.indexOf('\n', cur);
922
+ const end = nl === -1 ? input.length : nl;
923
+ setInput(input.slice(0, cur) + input.slice(end));
807
924
  setHistIdx(null);
925
+ vimCountRef.current = 0;
808
926
  return;
809
927
  }
810
928
  if (inputStr === 'j') {
811
929
  if (isFullscreen && maxTop > 0)
812
930
  commands.lineDown();
931
+ vimCountRef.current = 0;
813
932
  return;
814
933
  }
815
934
  if (inputStr === 'k') {
816
935
  if (isFullscreen && maxTop > 0)
817
936
  commands.lineUp();
937
+ vimCountRef.current = 0;
818
938
  return;
819
939
  }
820
940
  if (key.backspace || key.delete) {
821
941
  move(-1);
942
+ vimCountRef.current = 0;
822
943
  return;
823
944
  }
824
- if (key.escape)
825
- return; // already normal
826
- if (inputStr)
827
- return; // swallow everything else printable
945
+ if (key.escape) {
946
+ vimCountRef.current = 0;
947
+ vimPendingOp.current = null;
948
+ return;
949
+ } // already normal
950
+ if (inputStr) {
951
+ vimCountRef.current = 0;
952
+ vimPendingOp.current = null;
953
+ return;
954
+ } // swallow everything else printable
828
955
  }
829
956
  else if (key.backspace || key.delete) {
830
957
  move(-1);
@@ -1066,5 +1193,5 @@ export function App(props) {
1066
1193
  if (it.kind === 'diff')
1067
1194
  return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', redact(l.text)] }, j)))] }, i)))] }, it.id));
1068
1195
  return null;
1069
- }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", redact(p.title)] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", redact(q.slice(0, 60)), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen && !atBottom ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), vimMode === 'normal' ? _jsx(Text, { color: tokens.colors.warn, bold: true, children: "--NORMAL-- " }) : null, _jsx(InputWithCursor, { input: input, cursor: vimCursor })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
1196
+ }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", redact(p.title)] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", redact(q.slice(0, 60)), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen && !atBottom ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, slashSuggest.length === 0 && pathSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [pathSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " ", s.name] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), vimMode === 'normal' ? _jsx(Text, { color: tokens.colors.warn, bold: true, children: "--NORMAL-- " }) : null, _jsx(InputWithCursor, { input: input, cursor: vimCursor })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
1070
1197
  }
@@ -540,6 +540,30 @@ describe('App', () => {
540
540
  const frame = await waitForMatch(lastFrame, /REDACTED/);
541
541
  expect(frame).not.toMatch(/hunter2-hunter2/);
542
542
  });
543
+ it('vim counts, word motions, and dd edit the buffer', async () => {
544
+ let hooks = null;
545
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
546
+ hooks = { setVimMode: h.setVimMode };
547
+ } }));
548
+ await new Promise((r) => setTimeout(r, 50));
549
+ stdin.write('abcdef');
550
+ await waitForMatch(lastFrame, /abcdef/);
551
+ hooks.setVimMode('normal');
552
+ await waitForMatch(lastFrame, /--NORMAL--/);
553
+ // 0 then 3x deletes three chars → 'def'
554
+ stdin.write('0');
555
+ await new Promise((r) => setTimeout(r, 30));
556
+ stdin.write('3');
557
+ await new Promise((r) => setTimeout(r, 30));
558
+ stdin.write('x');
559
+ await waitForMatch(lastFrame, /def/);
560
+ expect(lastFrame() ?? '').not.toMatch(/abcdef/);
561
+ // dd clears the single-line buffer
562
+ stdin.write('d');
563
+ await new Promise((r) => setTimeout(r, 30));
564
+ stdin.write('d');
565
+ await waitForMatch(lastFrame, /Message Klyro/);
566
+ });
543
567
  it('vim normal mode swallows typing, shows mode, i returns to insert', async () => {
544
568
  let hooks = null;
545
569
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
@@ -74,13 +74,65 @@ export function ApprovalModal({ bridge }) {
74
74
  }, [bridge]);
75
75
  const [explain, setExplain] = useState(false);
76
76
  const [full, setFull] = useState(false);
77
+ // Inline edit mode (`e`): buffer holds the JSON input being edited.
78
+ // Enter submits (re-validated + re-prompted by the runtime), Esc cancels
79
+ // back to the choice keys. Reset whenever a new prompt arrives.
80
+ const [editing, setEditing] = useState(null);
81
+ const [editError, setEditError] = useState(null);
82
+ useEffect(() => {
83
+ setEditing(null);
84
+ setEditError(null);
85
+ }, [pending]);
77
86
  useInput((inputStr, key) => {
78
87
  if (!pending)
79
88
  return;
89
+ if (editing !== null) {
90
+ if (key.return) {
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(editing);
94
+ }
95
+ catch (err) {
96
+ setEditError(`invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
97
+ return;
98
+ }
99
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
+ setEditError('edited input must be a JSON object');
101
+ return;
102
+ }
103
+ bridge.resolve({ kind: 'edit', editedInput: parsed });
104
+ return;
105
+ }
106
+ if (key.escape) {
107
+ setEditing(null);
108
+ setEditError(null);
109
+ return;
110
+ }
111
+ if (key.backspace || key.delete) {
112
+ setEditing((v) => (v ?? '').slice(0, -1));
113
+ return;
114
+ }
115
+ if (!key.ctrl && !key.meta)
116
+ setEditing((v) => (v ?? '') + inputStr.replace(/\r/g, '\n'));
117
+ return;
118
+ }
80
119
  if (key.return) {
81
120
  bridge.resolve('deny');
82
121
  return;
83
122
  }
123
+ // `e` enters inline edit mode (real edit-and-retry); the stdin prompt
124
+ // keeps `e` as deny since it has no editor. Case-sensitive mapping for
125
+ // the rest lives in approvalChoiceForKey.
126
+ if (inputStr === 'e') {
127
+ try {
128
+ setEditing(JSON.stringify(pending.input ?? {}, null, 2));
129
+ }
130
+ catch {
131
+ setEditing('{}');
132
+ }
133
+ setEditError(null);
134
+ return;
135
+ }
84
136
  // Case-sensitive mapping lives in approvalChoiceForKey: 'A' persists,
85
137
  // 'a' is session-only (the old inline toLowerCase-first code collapsed
86
138
  // them, silently downgrading persistence).
@@ -107,5 +159,5 @@ export function ApprovalModal({ bridge }) {
107
159
  const clean = sanitizeForPrompt(s);
108
160
  return !full && clean.length > n ? clean.slice(0, n) + '…' : clean;
109
161
  };
110
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: \"", trunc(pending.reason), "\""] }), pending.summary ? _jsxs(Text, { children: [" \"", trunc(pending.summary), "\""] }) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [f] full text, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[f] full" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
162
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: \"", trunc(pending.reason), "\""] }), pending.summary ? _jsxs(Text, { children: [" \"", trunc(pending.summary), "\""] }) : null, editing !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: " editing input JSON (Enter = submit, Esc = back):" }), _jsxs(Text, { wrap: "wrap", children: [" ", (editing.length > 1500 ? editing.slice(0, 1500) + '…' : editing) + '▌'] }), editError ? _jsxs(Text, { color: "red", children: [" ", editError] }) : null] })) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [f] full text, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[f] full" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
111
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",