klyro 1.0.4 → 1.0.5

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 (46) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/runtime.js +11 -2
  3. package/dist/cli/auth.js +11 -3
  4. package/dist/cli/completion.js +63 -10
  5. package/dist/cli/doctor.js +13 -0
  6. package/dist/cli/repl.js +116 -10
  7. package/dist/cli/run.js +14 -0
  8. package/dist/cli/slash/parser.d.ts +7 -1
  9. package/dist/cli/slash/parser.js +37 -10
  10. package/dist/cli/update.d.ts +8 -4
  11. package/dist/cli/update.js +50 -7
  12. package/dist/context/accounting.d.ts +8 -0
  13. package/dist/context/accounting.js +18 -1
  14. package/dist/context/compaction.d.ts +1 -0
  15. package/dist/context/compaction.js +2 -1
  16. package/dist/index.js +78 -8
  17. package/dist/mcp/config.d.ts +7 -0
  18. package/dist/mcp/config.js +45 -0
  19. package/dist/mcp/registry.js +6 -4
  20. package/dist/mcp/serve.d.ts +23 -0
  21. package/dist/mcp/serve.js +111 -0
  22. package/dist/policy/engine.d.ts +11 -1
  23. package/dist/policy/engine.js +14 -1
  24. package/dist/policy/secret-redactor.js +4 -1
  25. package/dist/shared/error-map.d.ts +19 -0
  26. package/dist/shared/error-map.js +58 -0
  27. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  28. package/dist/tools/lsp/diagnostics.js +88 -9
  29. package/dist/tools/normalize.d.ts +3 -0
  30. package/dist/tools/normalize.js +8 -5
  31. package/dist/tools/search/dependencies.d.ts +2 -2
  32. package/dist/tools/shell/background.d.ts +6 -0
  33. package/dist/tools/shell/background.js +17 -0
  34. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  35. package/dist/tools/symbols/find-symbol.js +8 -6
  36. package/dist/tools/types.d.ts +8 -1
  37. package/dist/tui/app.d.ts +2 -0
  38. package/dist/tui/app.js +324 -50
  39. package/dist/tui/app.test.js +42 -3
  40. package/dist/tui/markdown.js +9 -0
  41. package/dist/tui/mouse.d.ts +26 -1
  42. package/dist/tui/mouse.js +104 -6
  43. package/dist/tui/scroll-flow.test.js +3 -1
  44. package/dist/tui/tokens.d.ts +6 -6
  45. package/dist/tui/tokens.js +9 -6
  46. package/package.json +1 -1
package/dist/tui/app.js CHANGED
@@ -13,6 +13,7 @@ import { tokens, g } from './tokens.js';
13
13
  import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, badgeLabel, } from './scroll-model.js';
14
14
  import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
15
15
  import { renderMarkdownLines } from './markdown.js';
16
+ import { redact } from '../policy/secret-redactor.js';
16
17
  import { readVersion } from '../version.js';
17
18
  import { getTranscriptCommand, } from './transcript-commands.js';
18
19
  import { estimateCost, getModelInfo } from '../providers/model-info.js';
@@ -85,6 +86,24 @@ function groupTools(items) {
85
86
  flush();
86
87
  return out;
87
88
  }
89
+ // Input line with an explicit block cursor for vim normal mode.
90
+ // Same single-<Text> invariant as MarkdownText: nested inline parts only,
91
+ // so the row-direction parent never lays siblings out as columns.
92
+ function InputWithCursor({ input, cursor }) {
93
+ const dimColor = tokens.colors.dim;
94
+ if (!input) {
95
+ return (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: dimColor, children: "Message Klyro..." }), "|"] }));
96
+ }
97
+ const safe = redact(input);
98
+ if (cursor === null) {
99
+ return _jsxs(Text, { wrap: "wrap", children: [safe, "|"] });
100
+ }
101
+ const pos = Math.max(0, Math.min(cursor, safe.length));
102
+ const before = safe.slice(0, pos);
103
+ const ch = safe.slice(pos, pos + 1) || ' ';
104
+ const after = safe.slice(pos + 1);
105
+ return (_jsxs(Text, { wrap: "wrap", children: [before, _jsx(Text, { inverse: true, children: ch }), after] }));
106
+ }
88
107
  // design.md §23/§24 — terminal Markdown via tui/markdown.ts: headings,
89
108
  // **bold**, *italic*, `code`, fences, links, lists. Ink wraps the text.
90
109
  //
@@ -93,7 +112,9 @@ function groupTools(items) {
93
112
  // out as side-by-side COLUMNS (garbled transcript) instead of lines.
94
113
  function MarkdownText({ text, dim, width }) {
95
114
  void width;
96
- const lines = useMemo(() => renderMarkdownLines(text), [text]);
115
+ // Display boundary: scrub secret shapes before painting the screen.
116
+ // Execution data is untouched — this only affects rendered output.
117
+ const lines = useMemo(() => renderMarkdownLines(redact(text)), [text]);
97
118
  const dimColor = tokens.colors.dim;
98
119
  const softColor = tokens.colors.soft;
99
120
  return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => {
@@ -227,6 +248,58 @@ export function App(props) {
227
248
  const t = setTimeout(toBottom, 0);
228
249
  return () => clearTimeout(t);
229
250
  }, [submitKey]);
251
+ // Double-Esc convention: the first Esc while running only arms (with a
252
+ // status hint); a second Esc within 1500ms cancels the stream. Single-Esc
253
+ // immediate cancel was too easy to hit by accident next to Shift+Enter.
254
+ const escArmedAt = useRef(0);
255
+ const [escArmed, setEscArmed] = useState(false);
256
+ const escArmTimer = useRef(null);
257
+ const disarmEsc = useCallback(() => {
258
+ escArmedAt.current = 0;
259
+ setEscArmed(false);
260
+ if (escArmTimer.current) {
261
+ clearTimeout(escArmTimer.current);
262
+ escArmTimer.current = null;
263
+ }
264
+ }, []);
265
+ // Minimal vim input mode (/vim toggles live via directHooks.setVimMode).
266
+ // Normal mode: h/l/0/$ move, x deletes, i/a enter insert, j/k scroll a
267
+ // line; every other printable key is swallowed (never typed). The cursor
268
+ // is an explicit index (null = end); rendering clamps it.
269
+ const [vimMode, setVimMode] = useState('insert');
270
+ const [vimCursor, setVimCursor] = useState(null);
271
+ const setVimModeLive = useCallback((m) => {
272
+ setVimMode(m);
273
+ if (m === 'insert')
274
+ setVimCursor(null);
275
+ }, []);
276
+ // Cursor-aware editing: mirrors for synchronous use inside key handlers.
277
+ const inputRef = useRef('');
278
+ const vimCursorRef = useRef(null);
279
+ useEffect(() => { inputRef.current = input; }, [input]);
280
+ useEffect(() => { vimCursorRef.current = vimCursor; }, [vimCursor]);
281
+ const clampCursor = (v, c) => c === null ? v.length : Math.max(0, Math.min(c, v.length));
282
+ const insertAtCursor = useCallback((text) => {
283
+ const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
284
+ if (!t)
285
+ return;
286
+ const v = inputRef.current;
287
+ const pos = clampCursor(v, vimCursorRef.current);
288
+ const nv = v.slice(0, pos) + t + v.slice(pos);
289
+ setInput(nv);
290
+ setVimCursor(pos + t.length >= nv.length ? null : pos + t.length);
291
+ setHistIdx(null);
292
+ }, []);
293
+ const deleteBeforeCursor = useCallback(() => {
294
+ const v = inputRef.current;
295
+ const pos = clampCursor(v, vimCursorRef.current);
296
+ if (pos === 0)
297
+ return;
298
+ const nv = v.slice(0, pos - 1) + v.slice(pos);
299
+ setInput(nv);
300
+ setVimCursor(pos - 1 >= nv.length ? null : pos - 1);
301
+ setHistIdx(null);
302
+ }, []);
230
303
  // Shift+Enter intent: explicit shift+return, kitty/CSI-u sequence, legacy
231
304
  // ESC+CR pair, or Esc immediately followed by Return (75ms, non-empty input).
232
305
  const escReturnAt = useRef(0);
@@ -239,6 +312,88 @@ export function App(props) {
239
312
  top: () => { },
240
313
  reset: () => { },
241
314
  });
315
+ // Streaming render throttle: per-token setState copies the whole
316
+ // transcript array AND the growing string (O(n²) over long streams).
317
+ // Deltas accumulate in refs (the truth) and flush to state at most
318
+ // every STREAM_FLUSH_MS, plus on turn end / new items. Renders stay
319
+ // live (≤64ms stale) while array+string copies stay bounded.
320
+ // Declared early: queued-input and key handlers below call resetStream.
321
+ const STREAM_FLUSH_MS = 64;
322
+ const streamTextRef = useRef('');
323
+ const streamFlushAt = useRef(0);
324
+ const streamTimer = useRef(null);
325
+ const thinkingTextRef = useRef('');
326
+ const thinkingFlushAt = useRef(0);
327
+ const thinkingTimer = useRef(null);
328
+ // Declared before the flush callbacks that capture it.
329
+ const thinkingIdRef = useRef(null);
330
+ const flushStream = useCallback(() => {
331
+ const sid = streamingIdRef.current;
332
+ if (streamTimer.current) {
333
+ clearTimeout(streamTimer.current);
334
+ streamTimer.current = null;
335
+ }
336
+ if (!sid)
337
+ return;
338
+ const text = streamTextRef.current;
339
+ streamFlushAt.current = Date.now();
340
+ setTranscript((prev) => {
341
+ const idx = prev.findIndex((x) => x.id === sid);
342
+ if (idx === -1)
343
+ return [...prev, { id: sid, kind: 'text', text, role: 'assistant' }];
344
+ const cur = prev[idx];
345
+ if (cur.text === text)
346
+ return prev;
347
+ const copy = [...prev];
348
+ copy[idx] = { ...cur, text };
349
+ return copy;
350
+ });
351
+ }, []);
352
+ const flushThinking = useCallback(() => {
353
+ const tid = thinkingIdRef.current;
354
+ if (thinkingTimer.current) {
355
+ clearTimeout(thinkingTimer.current);
356
+ thinkingTimer.current = null;
357
+ }
358
+ if (!tid)
359
+ return;
360
+ const text = thinkingTextRef.current;
361
+ thinkingFlushAt.current = Date.now();
362
+ setTranscript((prev) => {
363
+ const idx = prev.findIndex((x) => x.id === tid);
364
+ if (idx === -1)
365
+ return [...prev, { id: tid, kind: 'thinking', text }];
366
+ const cur = prev[idx];
367
+ if (cur.text === text)
368
+ return prev;
369
+ const copy = [...prev];
370
+ copy[idx] = { ...cur, text };
371
+ return copy;
372
+ });
373
+ }, []);
374
+ const resetStream = useCallback(() => {
375
+ streamingIdRef.current = null;
376
+ streamTextRef.current = '';
377
+ if (streamTimer.current) {
378
+ clearTimeout(streamTimer.current);
379
+ streamTimer.current = null;
380
+ }
381
+ }, []);
382
+ const resetThinking = useCallback(() => {
383
+ thinkingIdRef.current = null;
384
+ thinkingTextRef.current = '';
385
+ if (thinkingTimer.current) {
386
+ clearTimeout(thinkingTimer.current);
387
+ thinkingTimer.current = null;
388
+ }
389
+ }, []);
390
+ // Flush pending tail before unmount so no trailing delta is lost.
391
+ useEffect(() => () => {
392
+ if (streamTimer.current)
393
+ clearTimeout(streamTimer.current);
394
+ if (thinkingTimer.current)
395
+ clearTimeout(thinkingTimer.current);
396
+ }, []);
242
397
  const width = stdout?.columns ?? 100;
243
398
  const height = stdout?.rows ?? 30;
244
399
  const isFullscreen = props.isFullscreen ?? false;
@@ -394,60 +549,74 @@ export function App(props) {
394
549
  const toSend = queuedInputs[0];
395
550
  setQueuedInputs((prev) => prev.slice(1));
396
551
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: toSend, role: 'user' }]);
397
- streamingIdRef.current = null;
552
+ resetStream();
398
553
  const cmd = parseSlash(toSend.trim());
399
554
  if (cmd.kind === 'prompt')
400
555
  void props.onPrompt(cmd.text);
401
556
  else
402
557
  void props.onSlash(cmd);
403
558
  }
404
- }, [queuedInputs, status.status, awaitingApproval]);
559
+ }, [queuedInputs, status.status, awaitingApproval, resetStream]);
405
560
  useEffect(() => { if (status.status !== 'running')
406
561
  return; const start = Date.now() - elapsed; const t = setInterval(() => setElapsed(Date.now() - start), 1000); return () => clearInterval(t); }, [status.status, elapsed]);
407
- const append = useCallback((item) => { if (item.kind !== 'text' || item.role !== 'assistant')
408
- streamingIdRef.current = null; setTranscript((prev) => [...prev, item]); }, []);
562
+ const append = useCallback((item) => {
563
+ if (item.kind !== 'text' || item.role !== 'assistant') {
564
+ flushStream();
565
+ resetStream();
566
+ }
567
+ setTranscript((prev) => [...prev, item]);
568
+ }, [flushStream, resetStream]);
409
569
  const appendDelta = useCallback((text) => {
410
570
  if (!text)
411
571
  return;
412
- const sid = streamingIdRef.current;
413
- if (sid)
414
- setTranscript((prev) => { const idx = prev.findIndex((x) => x.id === sid); if (idx === -1)
415
- return [...prev, { id: sid, kind: 'text', text, role: 'assistant' }]; const cur = prev[idx]; const copy = [...prev]; copy[idx] = { ...cur, text: cur.text + text }; return copy; });
416
- else {
572
+ if (!streamingIdRef.current) {
417
573
  const id = nextId('stream');
418
574
  streamingIdRef.current = id;
419
- setTranscript((prev) => [...prev, { id, kind: 'text', text, role: 'assistant' }]);
575
+ streamTextRef.current = '';
576
+ streamFlushAt.current = 0;
420
577
  }
421
- }, []);
578
+ streamTextRef.current += text;
579
+ const now = Date.now();
580
+ if (now - streamFlushAt.current >= STREAM_FLUSH_MS)
581
+ flushStream();
582
+ else if (!streamTimer.current)
583
+ streamTimer.current = setTimeout(flushStream, STREAM_FLUSH_MS - (now - streamFlushAt.current));
584
+ }, [flushStream]);
422
585
  // Ephemeral reasoning display: merges into one transient item (never in
423
586
  // context/persistence); removed when the turn's answer completes.
424
- const thinkingIdRef = useRef(null);
425
587
  const appendThinkingDelta = useCallback((text) => {
426
588
  if (!text)
427
589
  return;
428
- const tid = thinkingIdRef.current;
429
- if (tid)
430
- setTranscript((prev) => { const idx = prev.findIndex((x) => x.id === tid); if (idx === -1)
431
- return [...prev, { id: tid, kind: 'thinking', text }]; const cur = prev[idx]; const copy = [...prev]; copy[idx] = { ...cur, text: cur.text + text }; return copy; });
432
- else {
590
+ if (!thinkingIdRef.current) {
433
591
  const id = nextId('thinking');
434
592
  thinkingIdRef.current = id;
435
- setTranscript((prev) => [...prev, { id, kind: 'thinking', text }]);
593
+ thinkingTextRef.current = '';
594
+ thinkingFlushAt.current = 0;
436
595
  }
437
- }, []);
596
+ thinkingTextRef.current += text;
597
+ const now = Date.now();
598
+ if (now - thinkingFlushAt.current >= STREAM_FLUSH_MS)
599
+ flushThinking();
600
+ else if (!thinkingTimer.current)
601
+ thinkingTimer.current = setTimeout(flushThinking, STREAM_FLUSH_MS - (now - thinkingFlushAt.current));
602
+ }, [flushThinking]);
438
603
  const clearThinking = useCallback(() => {
439
- thinkingIdRef.current = null;
604
+ resetThinking();
440
605
  setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
441
- }, []);
606
+ }, [resetThinking]);
442
607
  useEffect(() => {
443
608
  if (status.status !== 'running') {
444
- streamingIdRef.current = null;
445
- thinkingIdRef.current = null;
609
+ // Turn end: flush any throttled tail first so the final text is
610
+ // complete, then drop the transient thinking block as before.
611
+ flushStream();
612
+ resetStream();
613
+ resetThinking();
614
+ disarmEsc();
446
615
  // Runs that end without final_text (abort/cancel/error) must not leave
447
616
  // stale thinking blocks behind — only the response may remain.
448
617
  setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
449
618
  }
450
- }, [status.status]);
619
+ }, [status.status, flushStream, resetStream, resetThinking, disarmEsc]);
451
620
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
452
621
  const updatePlan = useCallback((p) => setPlan(p), []);
453
622
  // Tool results patch the running start-item IN PLACE (no second item, so a
@@ -465,14 +634,14 @@ export function App(props) {
465
634
  });
466
635
  }, []);
467
636
  const clearTranscript = useCallback(() => {
468
- streamingIdRef.current = null;
469
- thinkingIdRef.current = null;
637
+ resetStream();
638
+ resetThinking();
470
639
  setTranscript([]);
471
640
  setPlan([]);
472
641
  // Fresh content → fresh scroll (a pruned anchor would otherwise stick to
473
642
  // the bottom with a stale newSinceUnstick badge count).
474
643
  scrollCmdsRef.current.reset();
475
- }, []);
644
+ }, [resetStream, resetThinking]);
476
645
  // Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
477
646
  // Stored in refs so the callbacks stay stable while acting on latest state.
478
647
  const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
@@ -500,7 +669,12 @@ export function App(props) {
500
669
  }), []);
501
670
  const onMountedRef = useRef(props.onMounted);
502
671
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
503
- useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle, updateTool, appendThinkingDelta, clearThinking }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; globalThis.__klyroAppendThinking = appendThinkingDelta; globalThis.__klyroClearThinking = clearThinking; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; delete globalThis.__klyroAppendThinking; delete globalThis.__klyroClearThinking; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle, updateTool, appendThinkingDelta, clearThinking]);
672
+ // Bracketed-paste bulk insert: one atomic input append (no per-char
673
+ // autocomplete/history side effects) with newline normalization.
674
+ const pasteText = useCallback((text) => {
675
+ insertAtCursor(text);
676
+ }, [insertAtCursor]);
677
+ useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle, updateTool, appendThinkingDelta, clearThinking, pasteText, setVimMode: setVimModeLive }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; globalThis.__klyroAppendThinking = appendThinkingDelta; globalThis.__klyroClearThinking = clearThinking; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; delete globalThis.__klyroAppendThinking; delete globalThis.__klyroClearThinking; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle, updateTool, appendThinkingDelta, clearThinking, pasteText, setVimModeLive]);
504
678
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
505
679
  n.delete(id);
506
680
  else
@@ -512,12 +686,14 @@ export function App(props) {
512
686
  useInput((inputStr, key) => {
513
687
  if (key.escape && queuedInputs.length > 0) {
514
688
  setQueuedInputs((prev) => prev.slice(1));
689
+ disarmEsc();
515
690
  return;
516
691
  }
517
692
  // Tab completes the top slash suggestion (e.g. `/c` → `/clear `)
518
693
  if ((key.tab || inputStr === '\t') && slashSuggest.length > 0) {
519
694
  const top = slashSuggest[0];
520
695
  setInput(`/${top.name} `);
696
+ setVimCursor(null);
521
697
  return;
522
698
  }
523
699
  // Scroll keys (work in any mode, including while running).
@@ -581,10 +757,80 @@ export function App(props) {
581
757
  inputStr === '\x1b\r' ||
582
758
  inputStr === '\x1b[13;2u' ||
583
759
  (key.return && input.trim() !== '' && now - escReturnAt.current < 75)) {
584
- setInput((v) => v + '\n');
760
+ insertAtCursor('\n');
585
761
  setHistIdx(null);
586
762
  return;
587
763
  }
764
+ // Vim normal mode: motion/editing keys act on the input buffer instead
765
+ // of typing. Return/arrows/tab/ctrl fall through to the shared logic
766
+ // below; every other printable key is swallowed.
767
+ if (vimMode === 'normal' && !awaitingApproval) {
768
+ // Legacy Shift+Enter partner: an Esc that just switched modes followed
769
+ // by Return within 75ms still means newline, not submit.
770
+ if (key.return && input.trim() !== '' && Date.now() - escReturnAt.current < 75) {
771
+ insertAtCursor('\n');
772
+ setVimMode('insert');
773
+ return;
774
+ }
775
+ const cur = vimCursor === null ? input.length : Math.max(0, Math.min(vimCursor, input.length));
776
+ const move = (d) => { setVimCursor(Math.max(0, Math.min(cur + d, input.length))); return true; };
777
+ if (!key.ctrl && !key.meta && !key.return && !key.tab && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) {
778
+ if (inputStr === 'i') {
779
+ setVimMode('insert');
780
+ setVimCursor(null);
781
+ return;
782
+ }
783
+ if (inputStr === 'a') {
784
+ setVimCursor(Math.min(cur + 1, input.length));
785
+ setVimMode('insert');
786
+ return;
787
+ }
788
+ if (inputStr === 'h') {
789
+ move(-1);
790
+ return;
791
+ }
792
+ if (inputStr === 'l') {
793
+ move(1);
794
+ return;
795
+ }
796
+ if (inputStr === '0') {
797
+ setVimCursor(0);
798
+ return;
799
+ }
800
+ if (inputStr === '$') {
801
+ setVimCursor(input.length);
802
+ return;
803
+ }
804
+ if (inputStr === 'x') {
805
+ if (cur < input.length)
806
+ setInput(input.slice(0, cur) + input.slice(cur + 1));
807
+ setHistIdx(null);
808
+ return;
809
+ }
810
+ if (inputStr === 'j') {
811
+ if (isFullscreen && maxTop > 0)
812
+ commands.lineDown();
813
+ return;
814
+ }
815
+ if (inputStr === 'k') {
816
+ if (isFullscreen && maxTop > 0)
817
+ commands.lineUp();
818
+ return;
819
+ }
820
+ if (key.backspace || key.delete) {
821
+ move(-1);
822
+ return;
823
+ }
824
+ if (key.escape)
825
+ return; // already normal
826
+ if (inputStr)
827
+ return; // swallow everything else printable
828
+ }
829
+ else if (key.backspace || key.delete) {
830
+ move(-1);
831
+ return;
832
+ }
833
+ }
588
834
  if (status.status === 'running') {
589
835
  // design.md §18: first Ctrl+C cancels the run, second quits.
590
836
  if (key.ctrl && inputStr === 'c') {
@@ -597,9 +843,26 @@ export function App(props) {
597
843
  }
598
844
  return;
599
845
  }
600
- // Esc with an empty queue cancels streaming (§18); non-empty drops one (top).
846
+ // Esc no longer cancels on first press: with a queued item it drops
847
+ // one; otherwise the first Esc arms and the second (≤1500ms) cancels.
601
848
  if (key.escape) {
602
- void props.onSlash({ kind: 'cancel' });
849
+ if (queuedInputs.length > 0) {
850
+ setQueuedInputs((prev) => prev.slice(1));
851
+ disarmEsc();
852
+ return;
853
+ }
854
+ const t = Date.now();
855
+ if (t - escArmedAt.current < 1500) {
856
+ disarmEsc();
857
+ void props.onSlash({ kind: 'cancel' });
858
+ }
859
+ else {
860
+ escArmedAt.current = t;
861
+ setEscArmed(true);
862
+ if (escArmTimer.current)
863
+ clearTimeout(escArmTimer.current);
864
+ escArmTimer.current = setTimeout(disarmEsc, 1500);
865
+ }
603
866
  return;
604
867
  }
605
868
  // Enter on empty input dismisses the badge (jump to bottom, §7.2)
@@ -614,16 +877,17 @@ export function App(props) {
614
877
  return;
615
878
  setQueuedInputs((prev) => [...prev, v]);
616
879
  setInput('');
880
+ setVimCursor(null);
617
881
  pushHistory(v);
618
882
  setSubmitKey((k) => k + 1);
619
883
  return;
620
884
  }
621
885
  if (key.backspace || key.delete) {
622
- setInput((v) => v.slice(0, -1));
886
+ deleteBeforeCursor();
623
887
  return;
624
888
  }
625
889
  if (!key.ctrl && !key.meta)
626
- setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
890
+ insertAtCursor(inputStr);
627
891
  return;
628
892
  }
629
893
  // design.md §18: idle Ctrl+C exits.
@@ -639,6 +903,7 @@ export function App(props) {
639
903
  const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
640
904
  setHistIdx(next);
641
905
  setInput(history[next] ?? '');
906
+ setVimCursor(null);
642
907
  }
643
908
  return;
644
909
  }
@@ -658,6 +923,7 @@ export function App(props) {
658
923
  setHistIdx(next);
659
924
  setInput(history[next] ?? '');
660
925
  }
926
+ setVimCursor(null);
661
927
  return;
662
928
  }
663
929
  if (input.trim() !== '')
@@ -676,10 +942,11 @@ export function App(props) {
676
942
  return;
677
943
  }
678
944
  setInput('');
945
+ setVimCursor(null);
679
946
  setHistIdx(null);
680
947
  pushHistory(v);
681
948
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
682
- streamingIdRef.current = null;
949
+ resetStream();
683
950
  setSubmitKey((k) => k + 1);
684
951
  const cmd = parseSlash(v);
685
952
  if (cmd.kind === 'prompt')
@@ -689,13 +956,17 @@ export function App(props) {
689
956
  return;
690
957
  }
691
958
  if (key.backspace || key.delete) {
692
- setInput((v) => v.slice(0, -1));
693
- setHistIdx(null);
959
+ deleteBeforeCursor();
694
960
  return;
695
961
  }
696
962
  if (!key.ctrl && !key.meta) {
697
- setInput((v) => v + inputStr);
698
- setHistIdx(null);
963
+ insertAtCursor(inputStr);
964
+ }
965
+ // Idle Esc enters vim normal mode (vimLive parity for keyboard-only users).
966
+ if (key.escape && vimMode === 'insert') {
967
+ setVimMode('normal');
968
+ setVimCursor(inputRef.current.length);
969
+ return;
699
970
  }
700
971
  });
701
972
  // Single source of truth: package.json via version.ts — never hardcoded.
@@ -707,9 +978,12 @@ export function App(props) {
707
978
  const ctxWindow = getModelInfo(status.model).contextWindow;
708
979
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
709
980
  // Narrow terminals: compact hints so the status bar never wraps mid-word.
710
- const baseHints = width < 90
711
- ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
712
- : status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
981
+ // An armed first-Esc surfaces here so the operator knows the second cancels.
982
+ const baseHints = escArmed && status.status === 'running'
983
+ ? 'press esc again to cancel'
984
+ : width < 90
985
+ ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
986
+ : status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
713
987
  const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
714
988
  // I1 structural guard: the frame can never exceed terminal rows. Even if a
715
989
  // child mis-measures, the root clips — the input/status stay on screen and
@@ -751,7 +1025,7 @@ export function App(props) {
751
1025
  const marker = isExpanded ? g('expanded') : g('collapsed');
752
1026
  const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
753
1027
  const running = gr.status === 'running';
754
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), running ? _jsxs(Text, { color: markerColor, children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? (_jsxs(_Fragment, { children: [gr.items.slice(0, 12).map((it) => {
1028
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), running ? _jsxs(Text, { color: markerColor, children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsxs(Text, { color: markerColor, children: [marker, " ", redact(verbLine)] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? (_jsxs(_Fragment, { children: [gr.items.slice(0, 12).map((it) => {
755
1029
  let friendly = '';
756
1030
  try {
757
1031
  const a = JSON.parse(it.args);
@@ -769,12 +1043,12 @@ export function App(props) {
769
1043
  catch {
770
1044
  friendly = it.args.slice(0, 40);
771
1045
  }
772
- return (_jsxs(Box, { paddingLeft: 4, children: [_jsxs(Text, { color: tokens.colors.guide, children: [g('end'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: friendly })] }, it.id));
1046
+ return (_jsxs(Box, { paddingLeft: 4, children: [_jsxs(Text, { color: tokens.colors.guide, children: [g('end'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: redact(friendly) })] }, it.id));
773
1047
  }), gr.items.length > 12 ? (_jsx(Box, { paddingLeft: 4, children: _jsxs(Text, { color: tokens.colors.dim, children: ["\u2026 ", gr.items.length - 12, " more"] }) })) : null] })) : null] }, gr.id));
774
1048
  }
775
1049
  const it = item;
776
1050
  if (it.kind === 'text' && it.role === 'user') {
777
- return _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsx(Text, { wrap: "wrap", children: it.text })] }, it.id);
1051
+ return _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsx(Text, { wrap: "wrap", children: redact(it.text) })] }, it.id);
778
1052
  }
779
1053
  if (it.kind === 'text') {
780
1054
  // prose — render markdown, not raw **, with proper wrap and guide
@@ -782,15 +1056,15 @@ export function App(props) {
782
1056
  }
783
1057
  // Ephemeral reasoning: light-white while working, removed on response.
784
1058
  if (it.kind === 'thinking')
785
- return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsx(Text, { wrap: "wrap", color: tokens.colors.dim, children: it.text }) }, it.id);
1059
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsx(Text, { wrap: "wrap", color: tokens.colors.dim, children: redact(it.text) }) }, it.id);
786
1060
  if (it.kind === 'error')
787
- return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " ", g('failure'), " ", it.message] }) }, it.id);
1061
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " ", g('failure'), " ", redact(it.message)] }) }, it.id);
788
1062
  if (it.kind === 'policy')
789
1063
  return null;
790
1064
  if (it.kind === 'file_changed')
791
- return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.dim, children: [" ", g('guide'), " ", g('editsBadge'), " ", it.path, " ", it.op] }) }, it.id);
1065
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.dim, children: [" ", g('guide'), " ", g('editsBadge'), " ", redact(it.path), " ", it.op] }) }, it.id);
792
1066
  if (it.kind === 'diff')
793
- 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' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
1067
+ 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));
794
1068
  return null;
795
- }) : 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 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, ". ", 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: ", 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'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _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' ? ' ●' : ''] })] })] }));
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' ? ' ●' : ''] })] })] }));
796
1070
  }