klyro 1.0.4 → 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 (80) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +22 -2
  5. package/dist/agent/orchestrator.js +30 -4
  6. package/dist/agent/runtime.d.ts +5 -0
  7. package/dist/agent/runtime.js +174 -51
  8. package/dist/checkpoints/store.d.ts +9 -0
  9. package/dist/checkpoints/store.js +20 -0
  10. package/dist/cli/auth.js +11 -3
  11. package/dist/cli/completion.js +63 -10
  12. package/dist/cli/config.d.ts +4 -4
  13. package/dist/cli/doctor.js +13 -0
  14. package/dist/cli/eval.d.ts +15 -1
  15. package/dist/cli/eval.js +34 -2
  16. package/dist/cli/hooks.d.ts +54 -5
  17. package/dist/cli/hooks.js +85 -6
  18. package/dist/cli/init.d.ts +6 -0
  19. package/dist/cli/init.js +60 -0
  20. package/dist/cli/repl.js +261 -34
  21. package/dist/cli/run.d.ts +7 -1
  22. package/dist/cli/run.js +97 -41
  23. package/dist/cli/slash/custom.d.ts +25 -0
  24. package/dist/cli/slash/custom.js +166 -0
  25. package/dist/cli/slash/parser.d.ts +16 -2
  26. package/dist/cli/slash/parser.js +67 -18
  27. package/dist/cli/update.d.ts +8 -4
  28. package/dist/cli/update.js +50 -7
  29. package/dist/context/accounting.d.ts +8 -0
  30. package/dist/context/accounting.js +18 -1
  31. package/dist/context/compaction.d.ts +1 -0
  32. package/dist/context/compaction.js +2 -1
  33. package/dist/context/memory.js +18 -1
  34. package/dist/eval/harness.d.ts +21 -3
  35. package/dist/eval/harness.js +31 -3
  36. package/dist/eval/judge.d.ts +32 -0
  37. package/dist/eval/judge.js +63 -0
  38. package/dist/eval/tasks.js +134 -0
  39. package/dist/index.js +225 -132
  40. package/dist/mcp/client.d.ts +15 -0
  41. package/dist/mcp/client.js +42 -2
  42. package/dist/mcp/config.d.ts +10 -1
  43. package/dist/mcp/config.js +64 -1
  44. package/dist/mcp/registry.d.ts +13 -0
  45. package/dist/mcp/registry.js +47 -5
  46. package/dist/mcp/remote.d.ts +29 -0
  47. package/dist/mcp/remote.js +153 -0
  48. package/dist/mcp/serve.d.ts +23 -0
  49. package/dist/mcp/serve.js +111 -0
  50. package/dist/policy/approval.d.ts +15 -1
  51. package/dist/policy/approval.js +8 -0
  52. package/dist/policy/engine.d.ts +11 -1
  53. package/dist/policy/engine.js +14 -1
  54. package/dist/policy/secret-redactor.js +4 -1
  55. package/dist/providers/endpoints.d.ts +43 -0
  56. package/dist/providers/endpoints.js +104 -0
  57. package/dist/providers.js +13 -10
  58. package/dist/shared/error-map.d.ts +19 -0
  59. package/dist/shared/error-map.js +58 -0
  60. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  61. package/dist/tools/lsp/diagnostics.js +88 -9
  62. package/dist/tools/normalize.d.ts +3 -0
  63. package/dist/tools/normalize.js +8 -5
  64. package/dist/tools/search/dependencies.d.ts +2 -2
  65. package/dist/tools/shell/background.d.ts +6 -0
  66. package/dist/tools/shell/background.js +17 -0
  67. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  68. package/dist/tools/symbols/find-symbol.js +8 -6
  69. package/dist/tools/types.d.ts +8 -1
  70. package/dist/tui/app.d.ts +2 -0
  71. package/dist/tui/app.js +454 -53
  72. package/dist/tui/app.test.js +66 -3
  73. package/dist/tui/approval.js +53 -1
  74. package/dist/tui/markdown.js +9 -0
  75. package/dist/tui/mouse.d.ts +26 -1
  76. package/dist/tui/mouse.js +104 -6
  77. package/dist/tui/scroll-flow.test.js +3 -1
  78. package/dist/tui/tokens.d.ts +6 -6
  79. package/dist/tui/tokens.js +9 -6
  80. package/package.json +1 -1
package/dist/tui/app.js CHANGED
@@ -9,10 +9,12 @@ 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';
15
16
  import { renderMarkdownLines } from './markdown.js';
17
+ import { redact } from '../policy/secret-redactor.js';
16
18
  import { readVersion } from '../version.js';
17
19
  import { getTranscriptCommand, } from './transcript-commands.js';
18
20
  import { estimateCost, getModelInfo } from '../providers/model-info.js';
@@ -85,6 +87,24 @@ function groupTools(items) {
85
87
  flush();
86
88
  return out;
87
89
  }
90
+ // Input line with an explicit block cursor for vim normal mode.
91
+ // Same single-<Text> invariant as MarkdownText: nested inline parts only,
92
+ // so the row-direction parent never lays siblings out as columns.
93
+ function InputWithCursor({ input, cursor }) {
94
+ const dimColor = tokens.colors.dim;
95
+ if (!input) {
96
+ return (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: dimColor, children: "Message Klyro..." }), "|"] }));
97
+ }
98
+ const safe = redact(input);
99
+ if (cursor === null) {
100
+ return _jsxs(Text, { wrap: "wrap", children: [safe, "|"] });
101
+ }
102
+ const pos = Math.max(0, Math.min(cursor, safe.length));
103
+ const before = safe.slice(0, pos);
104
+ const ch = safe.slice(pos, pos + 1) || ' ';
105
+ const after = safe.slice(pos + 1);
106
+ return (_jsxs(Text, { wrap: "wrap", children: [before, _jsx(Text, { inverse: true, children: ch }), after] }));
107
+ }
88
108
  // design.md §23/§24 — terminal Markdown via tui/markdown.ts: headings,
89
109
  // **bold**, *italic*, `code`, fences, links, lists. Ink wraps the text.
90
110
  //
@@ -93,7 +113,9 @@ function groupTools(items) {
93
113
  // out as side-by-side COLUMNS (garbled transcript) instead of lines.
94
114
  function MarkdownText({ text, dim, width }) {
95
115
  void width;
96
- const lines = useMemo(() => renderMarkdownLines(text), [text]);
116
+ // Display boundary: scrub secret shapes before painting the screen.
117
+ // Execution data is untouched — this only affects rendered output.
118
+ const lines = useMemo(() => renderMarkdownLines(redact(text)), [text]);
97
119
  const dimColor = tokens.colors.dim;
98
120
  const softColor = tokens.colors.soft;
99
121
  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 +249,64 @@ export function App(props) {
227
249
  const t = setTimeout(toBottom, 0);
228
250
  return () => clearTimeout(t);
229
251
  }, [submitKey]);
252
+ // Double-Esc convention: the first Esc while running only arms (with a
253
+ // status hint); a second Esc within 1500ms cancels the stream. Single-Esc
254
+ // immediate cancel was too easy to hit by accident next to Shift+Enter.
255
+ const escArmedAt = useRef(0);
256
+ const [escArmed, setEscArmed] = useState(false);
257
+ const escArmTimer = useRef(null);
258
+ const disarmEsc = useCallback(() => {
259
+ escArmedAt.current = 0;
260
+ setEscArmed(false);
261
+ if (escArmTimer.current) {
262
+ clearTimeout(escArmTimer.current);
263
+ escArmTimer.current = null;
264
+ }
265
+ }, []);
266
+ // Minimal vim input mode (/vim toggles live via directHooks.setVimMode).
267
+ // Normal mode: h/l/0/$ move, x deletes, i/a enter insert, j/k scroll a
268
+ // line; every other printable key is swallowed (never typed). The cursor
269
+ // is an explicit index (null = end); rendering clamps it.
270
+ const [vimMode, setVimMode] = useState('insert');
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);
276
+ const setVimModeLive = useCallback((m) => {
277
+ setVimMode(m);
278
+ vimCountRef.current = 0;
279
+ vimPendingOp.current = null;
280
+ if (m === 'insert')
281
+ setVimCursor(null);
282
+ }, []);
283
+ // Cursor-aware editing: mirrors for synchronous use inside key handlers.
284
+ const inputRef = useRef('');
285
+ const vimCursorRef = useRef(null);
286
+ useEffect(() => { inputRef.current = input; }, [input]);
287
+ useEffect(() => { vimCursorRef.current = vimCursor; }, [vimCursor]);
288
+ const clampCursor = (v, c) => c === null ? v.length : Math.max(0, Math.min(c, v.length));
289
+ const insertAtCursor = useCallback((text) => {
290
+ const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
291
+ if (!t)
292
+ return;
293
+ const v = inputRef.current;
294
+ const pos = clampCursor(v, vimCursorRef.current);
295
+ const nv = v.slice(0, pos) + t + v.slice(pos);
296
+ setInput(nv);
297
+ setVimCursor(pos + t.length >= nv.length ? null : pos + t.length);
298
+ setHistIdx(null);
299
+ }, []);
300
+ const deleteBeforeCursor = useCallback(() => {
301
+ const v = inputRef.current;
302
+ const pos = clampCursor(v, vimCursorRef.current);
303
+ if (pos === 0)
304
+ return;
305
+ const nv = v.slice(0, pos - 1) + v.slice(pos);
306
+ setInput(nv);
307
+ setVimCursor(pos - 1 >= nv.length ? null : pos - 1);
308
+ setHistIdx(null);
309
+ }, []);
230
310
  // Shift+Enter intent: explicit shift+return, kitty/CSI-u sequence, legacy
231
311
  // ESC+CR pair, or Esc immediately followed by Return (75ms, non-empty input).
232
312
  const escReturnAt = useRef(0);
@@ -239,6 +319,88 @@ export function App(props) {
239
319
  top: () => { },
240
320
  reset: () => { },
241
321
  });
322
+ // Streaming render throttle: per-token setState copies the whole
323
+ // transcript array AND the growing string (O(n²) over long streams).
324
+ // Deltas accumulate in refs (the truth) and flush to state at most
325
+ // every STREAM_FLUSH_MS, plus on turn end / new items. Renders stay
326
+ // live (≤64ms stale) while array+string copies stay bounded.
327
+ // Declared early: queued-input and key handlers below call resetStream.
328
+ const STREAM_FLUSH_MS = 64;
329
+ const streamTextRef = useRef('');
330
+ const streamFlushAt = useRef(0);
331
+ const streamTimer = useRef(null);
332
+ const thinkingTextRef = useRef('');
333
+ const thinkingFlushAt = useRef(0);
334
+ const thinkingTimer = useRef(null);
335
+ // Declared before the flush callbacks that capture it.
336
+ const thinkingIdRef = useRef(null);
337
+ const flushStream = useCallback(() => {
338
+ const sid = streamingIdRef.current;
339
+ if (streamTimer.current) {
340
+ clearTimeout(streamTimer.current);
341
+ streamTimer.current = null;
342
+ }
343
+ if (!sid)
344
+ return;
345
+ const text = streamTextRef.current;
346
+ streamFlushAt.current = Date.now();
347
+ setTranscript((prev) => {
348
+ const idx = prev.findIndex((x) => x.id === sid);
349
+ if (idx === -1)
350
+ return [...prev, { id: sid, kind: 'text', text, role: 'assistant' }];
351
+ const cur = prev[idx];
352
+ if (cur.text === text)
353
+ return prev;
354
+ const copy = [...prev];
355
+ copy[idx] = { ...cur, text };
356
+ return copy;
357
+ });
358
+ }, []);
359
+ const flushThinking = useCallback(() => {
360
+ const tid = thinkingIdRef.current;
361
+ if (thinkingTimer.current) {
362
+ clearTimeout(thinkingTimer.current);
363
+ thinkingTimer.current = null;
364
+ }
365
+ if (!tid)
366
+ return;
367
+ const text = thinkingTextRef.current;
368
+ thinkingFlushAt.current = Date.now();
369
+ setTranscript((prev) => {
370
+ const idx = prev.findIndex((x) => x.id === tid);
371
+ if (idx === -1)
372
+ return [...prev, { id: tid, kind: 'thinking', text }];
373
+ const cur = prev[idx];
374
+ if (cur.text === text)
375
+ return prev;
376
+ const copy = [...prev];
377
+ copy[idx] = { ...cur, text };
378
+ return copy;
379
+ });
380
+ }, []);
381
+ const resetStream = useCallback(() => {
382
+ streamingIdRef.current = null;
383
+ streamTextRef.current = '';
384
+ if (streamTimer.current) {
385
+ clearTimeout(streamTimer.current);
386
+ streamTimer.current = null;
387
+ }
388
+ }, []);
389
+ const resetThinking = useCallback(() => {
390
+ thinkingIdRef.current = null;
391
+ thinkingTextRef.current = '';
392
+ if (thinkingTimer.current) {
393
+ clearTimeout(thinkingTimer.current);
394
+ thinkingTimer.current = null;
395
+ }
396
+ }, []);
397
+ // Flush pending tail before unmount so no trailing delta is lost.
398
+ useEffect(() => () => {
399
+ if (streamTimer.current)
400
+ clearTimeout(streamTimer.current);
401
+ if (thinkingTimer.current)
402
+ clearTimeout(thinkingTimer.current);
403
+ }, []);
242
404
  const width = stdout?.columns ?? 100;
243
405
  const height = stdout?.rows ?? 30;
244
406
  const isFullscreen = props.isFullscreen ?? false;
@@ -394,60 +556,74 @@ export function App(props) {
394
556
  const toSend = queuedInputs[0];
395
557
  setQueuedInputs((prev) => prev.slice(1));
396
558
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: toSend, role: 'user' }]);
397
- streamingIdRef.current = null;
559
+ resetStream();
398
560
  const cmd = parseSlash(toSend.trim());
399
561
  if (cmd.kind === 'prompt')
400
562
  void props.onPrompt(cmd.text);
401
563
  else
402
564
  void props.onSlash(cmd);
403
565
  }
404
- }, [queuedInputs, status.status, awaitingApproval]);
566
+ }, [queuedInputs, status.status, awaitingApproval, resetStream]);
405
567
  useEffect(() => { if (status.status !== 'running')
406
568
  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]); }, []);
569
+ const append = useCallback((item) => {
570
+ if (item.kind !== 'text' || item.role !== 'assistant') {
571
+ flushStream();
572
+ resetStream();
573
+ }
574
+ setTranscript((prev) => [...prev, item]);
575
+ }, [flushStream, resetStream]);
409
576
  const appendDelta = useCallback((text) => {
410
577
  if (!text)
411
578
  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 {
579
+ if (!streamingIdRef.current) {
417
580
  const id = nextId('stream');
418
581
  streamingIdRef.current = id;
419
- setTranscript((prev) => [...prev, { id, kind: 'text', text, role: 'assistant' }]);
582
+ streamTextRef.current = '';
583
+ streamFlushAt.current = 0;
420
584
  }
421
- }, []);
585
+ streamTextRef.current += text;
586
+ const now = Date.now();
587
+ if (now - streamFlushAt.current >= STREAM_FLUSH_MS)
588
+ flushStream();
589
+ else if (!streamTimer.current)
590
+ streamTimer.current = setTimeout(flushStream, STREAM_FLUSH_MS - (now - streamFlushAt.current));
591
+ }, [flushStream]);
422
592
  // Ephemeral reasoning display: merges into one transient item (never in
423
593
  // context/persistence); removed when the turn's answer completes.
424
- const thinkingIdRef = useRef(null);
425
594
  const appendThinkingDelta = useCallback((text) => {
426
595
  if (!text)
427
596
  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 {
597
+ if (!thinkingIdRef.current) {
433
598
  const id = nextId('thinking');
434
599
  thinkingIdRef.current = id;
435
- setTranscript((prev) => [...prev, { id, kind: 'thinking', text }]);
600
+ thinkingTextRef.current = '';
601
+ thinkingFlushAt.current = 0;
436
602
  }
437
- }, []);
603
+ thinkingTextRef.current += text;
604
+ const now = Date.now();
605
+ if (now - thinkingFlushAt.current >= STREAM_FLUSH_MS)
606
+ flushThinking();
607
+ else if (!thinkingTimer.current)
608
+ thinkingTimer.current = setTimeout(flushThinking, STREAM_FLUSH_MS - (now - thinkingFlushAt.current));
609
+ }, [flushThinking]);
438
610
  const clearThinking = useCallback(() => {
439
- thinkingIdRef.current = null;
611
+ resetThinking();
440
612
  setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
441
- }, []);
613
+ }, [resetThinking]);
442
614
  useEffect(() => {
443
615
  if (status.status !== 'running') {
444
- streamingIdRef.current = null;
445
- thinkingIdRef.current = null;
616
+ // Turn end: flush any throttled tail first so the final text is
617
+ // complete, then drop the transient thinking block as before.
618
+ flushStream();
619
+ resetStream();
620
+ resetThinking();
621
+ disarmEsc();
446
622
  // Runs that end without final_text (abort/cancel/error) must not leave
447
623
  // stale thinking blocks behind — only the response may remain.
448
624
  setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
449
625
  }
450
- }, [status.status]);
626
+ }, [status.status, flushStream, resetStream, resetThinking, disarmEsc]);
451
627
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
452
628
  const updatePlan = useCallback((p) => setPlan(p), []);
453
629
  // Tool results patch the running start-item IN PLACE (no second item, so a
@@ -465,14 +641,14 @@ export function App(props) {
465
641
  });
466
642
  }, []);
467
643
  const clearTranscript = useCallback(() => {
468
- streamingIdRef.current = null;
469
- thinkingIdRef.current = null;
644
+ resetStream();
645
+ resetThinking();
470
646
  setTranscript([]);
471
647
  setPlan([]);
472
648
  // Fresh content → fresh scroll (a pruned anchor would otherwise stick to
473
649
  // the bottom with a stale newSinceUnstick badge count).
474
650
  scrollCmdsRef.current.reset();
475
- }, []);
651
+ }, [resetStream, resetThinking]);
476
652
  // Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
477
653
  // Stored in refs so the callbacks stay stable while acting on latest state.
478
654
  const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
@@ -500,24 +676,60 @@ export function App(props) {
500
676
  }), []);
501
677
  const onMountedRef = useRef(props.onMounted);
502
678
  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]);
679
+ // Bracketed-paste bulk insert: one atomic input append (no per-char
680
+ // autocomplete/history side effects) with newline normalization.
681
+ const pasteText = useCallback((text) => {
682
+ insertAtCursor(text);
683
+ }, [insertAtCursor]);
684
+ 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
685
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
505
686
  n.delete(id);
506
687
  else
507
688
  n.add(id); return n; });
508
- // `/` 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]);
509
699
  const slashSuggest = input.startsWith('/') && !input.slice(1).includes(' ') && !awaitingApproval
510
- ? 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}` }))
511
715
  : [];
512
716
  useInput((inputStr, key) => {
513
717
  if (key.escape && queuedInputs.length > 0) {
514
718
  setQueuedInputs((prev) => prev.slice(1));
719
+ disarmEsc();
515
720
  return;
516
721
  }
517
- // Tab completes the top slash suggestion (e.g. `/c` → `/clear `)
722
+ // Tab completes the top slash or @-path suggestion
518
723
  if ((key.tab || inputStr === '\t') && slashSuggest.length > 0) {
519
724
  const top = slashSuggest[0];
520
725
  setInput(`/${top.name} `);
726
+ setVimCursor(null);
727
+ return;
728
+ }
729
+ if ((key.tab || inputStr === '\t') && pathSuggest.length > 0) {
730
+ const top = pathSuggest[0];
731
+ setInput(`${top.name} `);
732
+ setVimCursor(null);
521
733
  return;
522
734
  }
523
735
  // Scroll keys (work in any mode, including while running).
@@ -581,10 +793,171 @@ export function App(props) {
581
793
  inputStr === '\x1b\r' ||
582
794
  inputStr === '\x1b[13;2u' ||
583
795
  (key.return && input.trim() !== '' && now - escReturnAt.current < 75)) {
584
- setInput((v) => v + '\n');
796
+ insertAtCursor('\n');
585
797
  setHistIdx(null);
586
798
  return;
587
799
  }
800
+ // Vim normal mode: motion/editing keys act on the input buffer instead
801
+ // of typing. Return/arrows/tab/ctrl fall through to the shared logic
802
+ // below; every other printable key is swallowed. Supports counts
803
+ // (3h/2x), word motions (w/b), line ops (dd/D) — no operators/visual.
804
+ if (vimMode === 'normal' && !awaitingApproval) {
805
+ // Legacy Shift+Enter partner: an Esc that just switched modes followed
806
+ // by Return within 75ms still means newline, not submit.
807
+ if (key.return && input.trim() !== '' && Date.now() - escReturnAt.current < 75) {
808
+ insertAtCursor('\n');
809
+ setVimMode('insert');
810
+ return;
811
+ }
812
+ const cur = vimCursor === null ? input.length : Math.max(0, Math.min(vimCursor, input.length));
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
+ };
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
+ }
857
+ if (inputStr === 'i') {
858
+ setVimMode('insert');
859
+ setVimCursor(null);
860
+ vimCountRef.current = 0;
861
+ return;
862
+ }
863
+ if (inputStr === 'a') {
864
+ setVimCursor(Math.min(cur + 1, input.length));
865
+ setVimMode('insert');
866
+ vimCountRef.current = 0;
867
+ return;
868
+ }
869
+ if (/^[1-9]$/.test(inputStr)) {
870
+ vimCountRef.current = vimCountRef.current * 10 + Number(inputStr);
871
+ return;
872
+ }
873
+ if (inputStr === 'h') {
874
+ const n = takeCount();
875
+ move(-n);
876
+ return;
877
+ }
878
+ if (inputStr === 'l') {
879
+ const n = takeCount();
880
+ move(n);
881
+ return;
882
+ }
883
+ if (inputStr === '0' && vimCountRef.current === 0) {
884
+ setVimCursor(0);
885
+ return;
886
+ }
887
+ if (inputStr === '0') {
888
+ const n = takeCount();
889
+ move(-n);
890
+ return;
891
+ }
892
+ if (inputStr === '$') {
893
+ takeCount();
894
+ setVimCursor(input.length);
895
+ return;
896
+ }
897
+ if (inputStr === 'x') {
898
+ const n = takeCount();
899
+ if (cur < input.length)
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));
924
+ setHistIdx(null);
925
+ vimCountRef.current = 0;
926
+ return;
927
+ }
928
+ if (inputStr === 'j') {
929
+ if (isFullscreen && maxTop > 0)
930
+ commands.lineDown();
931
+ vimCountRef.current = 0;
932
+ return;
933
+ }
934
+ if (inputStr === 'k') {
935
+ if (isFullscreen && maxTop > 0)
936
+ commands.lineUp();
937
+ vimCountRef.current = 0;
938
+ return;
939
+ }
940
+ if (key.backspace || key.delete) {
941
+ move(-1);
942
+ vimCountRef.current = 0;
943
+ return;
944
+ }
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
955
+ }
956
+ else if (key.backspace || key.delete) {
957
+ move(-1);
958
+ return;
959
+ }
960
+ }
588
961
  if (status.status === 'running') {
589
962
  // design.md §18: first Ctrl+C cancels the run, second quits.
590
963
  if (key.ctrl && inputStr === 'c') {
@@ -597,9 +970,26 @@ export function App(props) {
597
970
  }
598
971
  return;
599
972
  }
600
- // Esc with an empty queue cancels streaming (§18); non-empty drops one (top).
973
+ // Esc no longer cancels on first press: with a queued item it drops
974
+ // one; otherwise the first Esc arms and the second (≤1500ms) cancels.
601
975
  if (key.escape) {
602
- void props.onSlash({ kind: 'cancel' });
976
+ if (queuedInputs.length > 0) {
977
+ setQueuedInputs((prev) => prev.slice(1));
978
+ disarmEsc();
979
+ return;
980
+ }
981
+ const t = Date.now();
982
+ if (t - escArmedAt.current < 1500) {
983
+ disarmEsc();
984
+ void props.onSlash({ kind: 'cancel' });
985
+ }
986
+ else {
987
+ escArmedAt.current = t;
988
+ setEscArmed(true);
989
+ if (escArmTimer.current)
990
+ clearTimeout(escArmTimer.current);
991
+ escArmTimer.current = setTimeout(disarmEsc, 1500);
992
+ }
603
993
  return;
604
994
  }
605
995
  // Enter on empty input dismisses the badge (jump to bottom, §7.2)
@@ -614,16 +1004,17 @@ export function App(props) {
614
1004
  return;
615
1005
  setQueuedInputs((prev) => [...prev, v]);
616
1006
  setInput('');
1007
+ setVimCursor(null);
617
1008
  pushHistory(v);
618
1009
  setSubmitKey((k) => k + 1);
619
1010
  return;
620
1011
  }
621
1012
  if (key.backspace || key.delete) {
622
- setInput((v) => v.slice(0, -1));
1013
+ deleteBeforeCursor();
623
1014
  return;
624
1015
  }
625
1016
  if (!key.ctrl && !key.meta)
626
- setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
1017
+ insertAtCursor(inputStr);
627
1018
  return;
628
1019
  }
629
1020
  // design.md §18: idle Ctrl+C exits.
@@ -639,6 +1030,7 @@ export function App(props) {
639
1030
  const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
640
1031
  setHistIdx(next);
641
1032
  setInput(history[next] ?? '');
1033
+ setVimCursor(null);
642
1034
  }
643
1035
  return;
644
1036
  }
@@ -658,6 +1050,7 @@ export function App(props) {
658
1050
  setHistIdx(next);
659
1051
  setInput(history[next] ?? '');
660
1052
  }
1053
+ setVimCursor(null);
661
1054
  return;
662
1055
  }
663
1056
  if (input.trim() !== '')
@@ -676,10 +1069,11 @@ export function App(props) {
676
1069
  return;
677
1070
  }
678
1071
  setInput('');
1072
+ setVimCursor(null);
679
1073
  setHistIdx(null);
680
1074
  pushHistory(v);
681
1075
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
682
- streamingIdRef.current = null;
1076
+ resetStream();
683
1077
  setSubmitKey((k) => k + 1);
684
1078
  const cmd = parseSlash(v);
685
1079
  if (cmd.kind === 'prompt')
@@ -689,13 +1083,17 @@ export function App(props) {
689
1083
  return;
690
1084
  }
691
1085
  if (key.backspace || key.delete) {
692
- setInput((v) => v.slice(0, -1));
693
- setHistIdx(null);
1086
+ deleteBeforeCursor();
694
1087
  return;
695
1088
  }
696
1089
  if (!key.ctrl && !key.meta) {
697
- setInput((v) => v + inputStr);
698
- setHistIdx(null);
1090
+ insertAtCursor(inputStr);
1091
+ }
1092
+ // Idle Esc enters vim normal mode (vimLive parity for keyboard-only users).
1093
+ if (key.escape && vimMode === 'insert') {
1094
+ setVimMode('normal');
1095
+ setVimCursor(inputRef.current.length);
1096
+ return;
699
1097
  }
700
1098
  });
701
1099
  // Single source of truth: package.json via version.ts — never hardcoded.
@@ -707,9 +1105,12 @@ export function App(props) {
707
1105
  const ctxWindow = getModelInfo(status.model).contextWindow;
708
1106
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
709
1107
  // 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';
1108
+ // An armed first-Esc surfaces here so the operator knows the second cancels.
1109
+ const baseHints = escArmed && status.status === 'running'
1110
+ ? 'press esc again to cancel'
1111
+ : width < 90
1112
+ ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
1113
+ : 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
1114
  const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
714
1115
  // I1 structural guard: the frame can never exceed terminal rows. Even if a
715
1116
  // child mis-measures, the root clips — the input/status stay on screen and
@@ -751,7 +1152,7 @@ export function App(props) {
751
1152
  const marker = isExpanded ? g('expanded') : g('collapsed');
752
1153
  const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
753
1154
  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) => {
1155
+ 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
1156
  let friendly = '';
756
1157
  try {
757
1158
  const a = JSON.parse(it.args);
@@ -769,12 +1170,12 @@ export function App(props) {
769
1170
  catch {
770
1171
  friendly = it.args.slice(0, 40);
771
1172
  }
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));
1173
+ 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
1174
  }), 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
1175
  }
775
1176
  const it = item;
776
1177
  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);
1178
+ 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
1179
  }
779
1180
  if (it.kind === 'text') {
780
1181
  // prose — render markdown, not raw **, with proper wrap and guide
@@ -782,15 +1183,15 @@ export function App(props) {
782
1183
  }
783
1184
  // Ephemeral reasoning: light-white while working, removed on response.
784
1185
  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);
1186
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsx(Text, { wrap: "wrap", color: tokens.colors.dim, children: redact(it.text) }) }, it.id);
786
1187
  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);
1188
+ 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
1189
  if (it.kind === 'policy')
789
1190
  return null;
790
1191
  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);
1192
+ 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
1193
  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));
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));
794
1195
  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' ? ' ●' : ''] })] })] }));
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' ? ' ●' : ''] })] })] }));
796
1197
  }