klyro 1.0.3 → 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 (48) 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 +330 -52
  39. package/dist/tui/app.test.js +45 -6
  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 +4 -2
  44. package/dist/tui/scroll-model.d.ts +3 -1
  45. package/dist/tui/scroll-model.js +6 -5
  46. package/dist/tui/tokens.d.ts +6 -6
  47. package/dist/tui/tokens.js +9 -6
  48. package/package.json +1 -1
package/dist/tui/app.js CHANGED
@@ -10,9 +10,10 @@ 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
12
  import { tokens, g } from './tokens.js';
13
- import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
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;
@@ -322,7 +477,7 @@ export function App(props) {
322
477
  const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
323
478
  const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
324
479
  const scroll = useChatScroll({ keys: blockKeys, heights: blockHeights, viewportH, width });
325
- const { topRow, maxTop, pinned, pendingNew, commands } = scroll;
480
+ const { topRow, maxTop, pinned, pendingNew, commands, atBottom } = scroll;
326
481
  const trackH = viewportH;
327
482
  const thumbPos = maxTop === 0 ? 0 : Math.round((topRow / maxTop) * (trackH - 1));
328
483
  // Slice *display lines* [topRow, topRow+viewportH): intersecting blocks only
@@ -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).
@@ -538,6 +714,10 @@ export function App(props) {
538
714
  commands.jumpBottom();
539
715
  return;
540
716
  }
717
+ if (inputStr === ' ' && pinned && pendingNew > 0) {
718
+ commands.jumpBottom();
719
+ return;
720
+ } // Space → jump to unread tail
541
721
  if (key.ctrl && inputStr === 'g') {
542
722
  commands.jumpBottom();
543
723
  return;
@@ -577,10 +757,80 @@ export function App(props) {
577
757
  inputStr === '\x1b\r' ||
578
758
  inputStr === '\x1b[13;2u' ||
579
759
  (key.return && input.trim() !== '' && now - escReturnAt.current < 75)) {
580
- setInput((v) => v + '\n');
760
+ insertAtCursor('\n');
581
761
  setHistIdx(null);
582
762
  return;
583
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
+ }
584
834
  if (status.status === 'running') {
585
835
  // design.md §18: first Ctrl+C cancels the run, second quits.
586
836
  if (key.ctrl && inputStr === 'c') {
@@ -593,9 +843,26 @@ export function App(props) {
593
843
  }
594
844
  return;
595
845
  }
596
- // 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.
597
848
  if (key.escape) {
598
- 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
+ }
599
866
  return;
600
867
  }
601
868
  // Enter on empty input dismisses the badge (jump to bottom, §7.2)
@@ -610,16 +877,17 @@ export function App(props) {
610
877
  return;
611
878
  setQueuedInputs((prev) => [...prev, v]);
612
879
  setInput('');
880
+ setVimCursor(null);
613
881
  pushHistory(v);
614
882
  setSubmitKey((k) => k + 1);
615
883
  return;
616
884
  }
617
885
  if (key.backspace || key.delete) {
618
- setInput((v) => v.slice(0, -1));
886
+ deleteBeforeCursor();
619
887
  return;
620
888
  }
621
889
  if (!key.ctrl && !key.meta)
622
- setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
890
+ insertAtCursor(inputStr);
623
891
  return;
624
892
  }
625
893
  // design.md §18: idle Ctrl+C exits.
@@ -635,6 +903,7 @@ export function App(props) {
635
903
  const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
636
904
  setHistIdx(next);
637
905
  setInput(history[next] ?? '');
906
+ setVimCursor(null);
638
907
  }
639
908
  return;
640
909
  }
@@ -654,6 +923,7 @@ export function App(props) {
654
923
  setHistIdx(next);
655
924
  setInput(history[next] ?? '');
656
925
  }
926
+ setVimCursor(null);
657
927
  return;
658
928
  }
659
929
  if (input.trim() !== '')
@@ -672,10 +942,11 @@ export function App(props) {
672
942
  return;
673
943
  }
674
944
  setInput('');
945
+ setVimCursor(null);
675
946
  setHistIdx(null);
676
947
  pushHistory(v);
677
948
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
678
- streamingIdRef.current = null;
949
+ resetStream();
679
950
  setSubmitKey((k) => k + 1);
680
951
  const cmd = parseSlash(v);
681
952
  if (cmd.kind === 'prompt')
@@ -685,13 +956,17 @@ export function App(props) {
685
956
  return;
686
957
  }
687
958
  if (key.backspace || key.delete) {
688
- setInput((v) => v.slice(0, -1));
689
- setHistIdx(null);
959
+ deleteBeforeCursor();
690
960
  return;
691
961
  }
692
962
  if (!key.ctrl && !key.meta) {
693
- setInput((v) => v + inputStr);
694
- 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;
695
970
  }
696
971
  });
697
972
  // Single source of truth: package.json via version.ts — never hardcoded.
@@ -703,9 +978,12 @@ export function App(props) {
703
978
  const ctxWindow = getModelInfo(status.model).contextWindow;
704
979
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
705
980
  // Narrow terminals: compact hints so the status bar never wraps mid-word.
706
- const baseHints = width < 90
707
- ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
708
- : 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';
709
987
  const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
710
988
  // I1 structural guard: the frame can never exceed terminal rows. Even if a
711
989
  // child mis-measures, the root clips — the input/status stay on screen and
@@ -747,7 +1025,7 @@ export function App(props) {
747
1025
  const marker = isExpanded ? g('expanded') : g('collapsed');
748
1026
  const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
749
1027
  const running = gr.status === 'running';
750
- 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) => {
751
1029
  let friendly = '';
752
1030
  try {
753
1031
  const a = JSON.parse(it.args);
@@ -765,12 +1043,12 @@ export function App(props) {
765
1043
  catch {
766
1044
  friendly = it.args.slice(0, 40);
767
1045
  }
768
- 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));
769
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));
770
1048
  }
771
1049
  const it = item;
772
1050
  if (it.kind === 'text' && it.role === 'user') {
773
- 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);
774
1052
  }
775
1053
  if (it.kind === 'text') {
776
1054
  // prose — render markdown, not raw **, with proper wrap and guide
@@ -778,15 +1056,15 @@ export function App(props) {
778
1056
  }
779
1057
  // Ephemeral reasoning: light-white while working, removed on response.
780
1058
  if (it.kind === 'thinking')
781
- 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);
782
1060
  if (it.kind === 'error')
783
- 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);
784
1062
  if (it.kind === 'policy')
785
1063
  return null;
786
1064
  if (it.kind === 'file_changed')
787
- 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);
788
1066
  if (it.kind === 'diff')
789
- 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));
790
1068
  return null;
791
- }) : 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 ? (_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 && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : 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' ? ' ●' : ''] })] })] }));
792
1070
  }