@bubblebrain-ai/bubble 0.0.20 → 0.0.22

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 (98) hide show
  1. package/dist/agent/abort-errors.d.ts +14 -0
  2. package/dist/agent/abort-errors.js +21 -0
  3. package/dist/agent/budget-ledger.d.ts +41 -0
  4. package/dist/agent/budget-ledger.js +64 -0
  5. package/dist/agent/child-runner.d.ts +55 -0
  6. package/dist/agent/child-runner.js +312 -0
  7. package/dist/agent/profiles.d.ts +8 -0
  8. package/dist/agent/profiles.js +27 -5
  9. package/dist/agent/result-integrator.d.ts +22 -0
  10. package/dist/agent/result-integrator.js +50 -0
  11. package/dist/agent/subagent-control.d.ts +31 -0
  12. package/dist/agent/subagent-control.js +27 -0
  13. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  14. package/dist/agent/subagent-scheduler.d.ts +95 -0
  15. package/dist/agent/subagent-scheduler.js +256 -0
  16. package/dist/agent/subagent-store.d.ts +41 -0
  17. package/dist/agent/subagent-store.js +149 -0
  18. package/dist/agent/subagent-summary.d.ts +30 -0
  19. package/dist/agent/subagent-summary.js +74 -0
  20. package/dist/agent/worktree.d.ts +29 -0
  21. package/dist/agent/worktree.js +73 -0
  22. package/dist/agent.d.ts +64 -5
  23. package/dist/agent.js +365 -288
  24. package/dist/approval/controller.js +9 -1
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +17 -1
  27. package/dist/checkpoints.d.ts +57 -0
  28. package/dist/checkpoints.js +0 -0
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.js +17 -0
  31. package/dist/feishu/agent-host/approval-card.js +9 -0
  32. package/dist/feishu/agent-host/run-driver.js +2 -0
  33. package/dist/main.js +88 -13
  34. package/dist/network/errors.d.ts +28 -0
  35. package/dist/network/errors.js +24 -0
  36. package/dist/orchestrator/default-hooks.js +5 -1
  37. package/dist/prompt/compose.js +3 -0
  38. package/dist/prompt/delegation.d.ts +14 -0
  39. package/dist/prompt/delegation.js +64 -0
  40. package/dist/prompt/task-reminders.d.ts +5 -1
  41. package/dist/prompt/task-reminders.js +10 -2
  42. package/dist/provider-anthropic.js +23 -0
  43. package/dist/provider.js +23 -3
  44. package/dist/session.d.ts +31 -0
  45. package/dist/session.js +69 -0
  46. package/dist/slash-commands/commands.js +109 -2
  47. package/dist/slash-commands/types.d.ts +6 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/bash.js +4 -0
  51. package/dist/tools/child-tools.d.ts +31 -0
  52. package/dist/tools/child-tools.js +106 -0
  53. package/dist/tools/edit.d.ts +2 -1
  54. package/dist/tools/edit.js +2 -1
  55. package/dist/tools/index.d.ts +7 -0
  56. package/dist/tools/index.js +3 -3
  57. package/dist/tools/write.d.ts +2 -1
  58. package/dist/tools/write.js +2 -1
  59. package/dist/tui/image-paste.d.ts +18 -0
  60. package/dist/tui/image-paste.js +60 -0
  61. package/dist/tui/run.d.ts +11 -1
  62. package/dist/tui/run.js +399 -71
  63. package/dist/tui/session-picker-data.d.ts +18 -0
  64. package/dist/tui/session-picker-data.js +21 -0
  65. package/dist/tui/trace-groups.d.ts +16 -0
  66. package/dist/tui/trace-groups.js +42 -1
  67. package/dist/tui/transcript-scroll.d.ts +25 -0
  68. package/dist/tui/transcript-scroll.js +20 -0
  69. package/dist/tui/wordmark.d.ts +2 -0
  70. package/dist/tui/wordmark.js +31 -4
  71. package/dist/tui-ink/app.d.ts +4 -1
  72. package/dist/tui-ink/app.js +301 -247
  73. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  74. package/dist/tui-ink/display-history.d.ts +16 -1
  75. package/dist/tui-ink/display-history.js +50 -21
  76. package/dist/tui-ink/footer.d.ts +6 -12
  77. package/dist/tui-ink/footer.js +10 -29
  78. package/dist/tui-ink/image-paste.d.ts +59 -0
  79. package/dist/tui-ink/image-paste.js +277 -0
  80. package/dist/tui-ink/input-box.d.ts +26 -1
  81. package/dist/tui-ink/input-box.js +171 -41
  82. package/dist/tui-ink/message-list.d.ts +1 -1
  83. package/dist/tui-ink/message-list.js +46 -29
  84. package/dist/tui-ink/run.d.ts +7 -2
  85. package/dist/tui-ink/run.js +73 -23
  86. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  87. package/dist/tui-ink/terminal-mouse.js +4 -0
  88. package/dist/tui-ink/trace-groups.d.ts +16 -0
  89. package/dist/tui-ink/trace-groups.js +50 -2
  90. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  91. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  92. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  93. package/dist/tui-ink/transcript-viewport.js +83 -0
  94. package/dist/tui-ink/welcome.d.ts +9 -7
  95. package/dist/tui-ink/welcome.js +7 -33
  96. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  97. package/dist/types.d.ts +17 -0
  98. package/package.json +1 -1
@@ -10,6 +10,16 @@ export interface SubmitPayload {
10
10
  }
11
11
  interface InputBoxProps {
12
12
  onSubmit: (payload: SubmitPayload) => void;
13
+ /**
14
+ * When set (agent running), Tab queues the composer content for the next
15
+ * turn instead of its idle-time behavior.
16
+ */
17
+ onQueue?: (payload: SubmitPayload) => void;
18
+ /**
19
+ * Receives scroll intent when Up/Down arrows are classified as synthetic
20
+ * wheel events (terminal alternate-scroll) rather than keyboard presses.
21
+ */
22
+ onWheelScroll?: (direction: "up" | "down", lines: number) => void;
13
23
  onPasteNotice?: (notice: string) => void;
14
24
  disabled?: boolean;
15
25
  cursorResetEpoch?: number;
@@ -31,6 +41,21 @@ export declare function resolveCursorRowCompensation(input: {
31
41
  export declare function isCtrlCInput(input: string, key: {
32
42
  ctrl?: boolean;
33
43
  }): boolean;
44
+ /**
45
+ * Split a composer line around the cursor so the cell under it can render as
46
+ * an inverse-video software cursor. The visible cursor must not depend on the
47
+ * real terminal cursor: Ink only re-arms its one-shot cursor escape when the
48
+ * component owning useCursor re-commits, so frames produced by other
49
+ * components' local state (the waiting spinner, viewport scrolling) hide the
50
+ * hardware cursor for most of an agent run. Drawing the cell ourselves keeps
51
+ * the cursor visible on every frame; the real (mostly hidden) cursor is still
52
+ * positioned for IME anchoring.
53
+ */
54
+ export declare function splitLineAtCursor(lineText: string, charOffset: number): {
55
+ before: string;
56
+ at: string;
57
+ after: string;
58
+ };
34
59
  export declare function shouldSubmitExactSlashSuggestion(input: string, suggestionName?: string): boolean;
35
60
  export declare function resolveSlashEnterAction(input: string, suggestions: Array<{
36
61
  name: string;
@@ -55,4 +80,4 @@ export declare function insertNewlineAtCursor(text: string, cursor: number): {
55
80
  text: string;
56
81
  cursor: number;
57
82
  };
58
- export declare function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
83
+ export declare function InputBox({ onSubmit, onQueue, onWheelScroll, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useCursor, useInput, usePaste, useStdout } from "ink";
4
4
  import stringWidth from "string-width";
@@ -6,7 +6,7 @@ import { appendFileSync } from "node:fs";
6
6
  import { registry as slashRegistry } from "../slash-commands/index.js";
7
7
  import { useTheme } from "./theme.js";
8
8
  import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
9
- import { ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
9
+ import { bareImageFilenameFromPaste, ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
10
10
  import { appendHistoryEntry, loadHistorySync, pushHistoryEntry, stepHistory, } from "./input-history.js";
11
11
  import { stripTerminalMouseSequences } from "./terminal-mouse.js";
12
12
  export { createPastedContentMarker, expandPastedContentMarkers, shouldCollapsePastedContent, } from "../tui/paste-placeholder.js";
@@ -37,6 +37,29 @@ export function resolveCursorRowCompensation(input) {
37
37
  export function isCtrlCInput(input, key) {
38
38
  return input === "\x03" || (key.ctrl === true && input.toLowerCase() === "c");
39
39
  }
40
+ /**
41
+ * Split a composer line around the cursor so the cell under it can render as
42
+ * an inverse-video software cursor. The visible cursor must not depend on the
43
+ * real terminal cursor: Ink only re-arms its one-shot cursor escape when the
44
+ * component owning useCursor re-commits, so frames produced by other
45
+ * components' local state (the waiting spinner, viewport scrolling) hide the
46
+ * hardware cursor for most of an agent run. Drawing the cell ourselves keeps
47
+ * the cursor visible on every frame; the real (mostly hidden) cursor is still
48
+ * positioned for IME anchoring.
49
+ */
50
+ export function splitLineAtCursor(lineText, charOffset) {
51
+ const offset = Math.max(0, Math.min(charOffset, lineText.length));
52
+ if (offset >= lineText.length) {
53
+ return { before: lineText, at: " ", after: "" };
54
+ }
55
+ const codePoint = lineText.codePointAt(offset);
56
+ const length = codePoint > 0xffff ? 2 : 1;
57
+ return {
58
+ before: lineText.slice(0, offset),
59
+ at: lineText.slice(offset, offset + length),
60
+ after: lineText.slice(offset + length),
61
+ };
62
+ }
40
63
  // Break a logical line into segments that each fit within `maxWidth` display
41
64
  // columns. Uses string-width so CJK and emoji wrap correctly; empty lines
42
65
  // still produce one empty segment so cursors on blank lines render.
@@ -148,7 +171,7 @@ export function insertNewlineAtCursor(text, cursor) {
148
171
  cursor: clampedCursor + 1,
149
172
  };
150
173
  }
151
- export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, terminalColumns, cwd, }) {
174
+ export function InputBox({ onSubmit, onQueue, onWheelScroll, onPasteNotice, disabled, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, terminalColumns, cwd, }) {
152
175
  const theme = useTheme();
153
176
  const width = terminalColumns;
154
177
  const [text, setText] = useState("");
@@ -285,6 +308,20 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
285
308
  }
286
309
  return;
287
310
  }
311
+ // Copying an image file in Finder pastes only the file's NAME while the
312
+ // real bits stay on the system clipboard — attach from there. If the
313
+ // clipboard turns out to hold no image, it was just text: insert it
314
+ // quietly.
315
+ const bareName = bareImageFilenameFromPaste(clean);
316
+ if (bareName && process.platform === "darwin") {
317
+ void tryClipboardImage()
318
+ .then((attached) => {
319
+ if (!attached)
320
+ insertTextAtCursor(clean);
321
+ })
322
+ .finally(clearPending);
323
+ return;
324
+ }
288
325
  // Look for image paths inside the paste (drag-and-drop from Finder/
289
326
  // Nautilus/Explorer). Multi-selection can arrive newline- or
290
327
  // space-separated.
@@ -352,11 +389,12 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
352
389
  setCursor(before.length + insert.length);
353
390
  setSelectedIndex(0);
354
391
  };
355
- const submitInput = (submittedText) => {
392
+ const submitInput = (submittedText, target = "submit") => {
356
393
  const expandedText = expandPastedContentMarkers(submittedText, pastedContentRefs);
357
394
  if (expandedText.trim().length === 0 && attachments.length === 0)
358
395
  return;
359
- onSubmit({
396
+ const deliver = target === "queue" && onQueue ? onQueue : onSubmit;
397
+ deliver({
360
398
  text: expandedText,
361
399
  displayText: expandedText === submittedText ? undefined : submittedText,
362
400
  images: attachments,
@@ -481,6 +519,14 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
481
519
  }
482
520
  }
483
521
  }
522
+ // While the agent runs, Tab queues the composer content for the next
523
+ // turn (Enter steers — handled by the app-level submit routing).
524
+ if (key.tab && !key.shift && onQueue && !showSuggestions) {
525
+ if (pastePendingRef.current)
526
+ return;
527
+ submitInput(text, "queue");
528
+ return;
529
+ }
484
530
  if (enterIntent === "submit") {
485
531
  // A paste is still mid-flight — dropping this Enter avoids submitting
486
532
  // an input state that doesn't yet include the paste.
@@ -514,45 +560,23 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
514
560
  setSelectedIndex(0);
515
561
  return;
516
562
  }
517
- if (key.upArrow) {
518
- if (cursorVisualRow > 0) {
519
- setCursor(visualToCursor(visualLines, cursorVisualRow - 1, cursorVisualCol));
520
- return;
521
- }
522
- const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, "up", text);
523
- if (result.changed) {
524
- setText(result.text);
525
- setCursor(result.text.length);
526
- setHistoryIndex(result.index);
527
- historyDraftRef.current = result.draft;
528
- setSelectedIndex(0);
529
- setPastedContentRefs([]);
530
- nextPastedContentIndexRef.current = 1;
531
- }
563
+ if (key.upArrow || key.downArrow) {
564
+ classifyVerticalArrow(key.upArrow ? "up" : "down", key.eventType);
532
565
  return;
533
566
  }
534
- if (key.downArrow) {
535
- if (cursorVisualRow < visualLines.length - 1) {
536
- setCursor(visualToCursor(visualLines, cursorVisualRow + 1, cursorVisualCol));
537
- return;
538
- }
539
- const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, "down", text);
540
- if (result.changed) {
541
- setText(result.text);
542
- setCursor(result.text.length);
543
- setHistoryIndex(result.index);
544
- historyDraftRef.current = result.draft;
545
- setSelectedIndex(0);
546
- setPastedContentRefs([]);
547
- nextPastedContentIndexRef.current = 1;
548
- }
567
+ // Ctrl/meta chords are app-level shortcuts (Ctrl+S selection mode,
568
+ // Ctrl+O trace, Ctrl+R thinking, ) — never type their letter. Raw C0
569
+ // control bytes (kitty protocol off) are equally not text.
570
+ if (key.ctrl || key.meta)
549
571
  return;
550
- }
551
572
  if (input) {
573
+ const printable = input.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
574
+ if (!printable)
575
+ return;
552
576
  const before = text.slice(0, cursor);
553
577
  const after = text.slice(cursor);
554
- setText(before + input + after);
555
- setCursor(cursor + input.length);
578
+ setText(before + printable + after);
579
+ setCursor(cursor + printable.length);
556
580
  setSelectedIndex(0);
557
581
  }
558
582
  });
@@ -620,6 +644,103 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
620
644
  const lineWidth = Math.max(1, contentWidth - PROMPT.length);
621
645
  const visualLines = useMemo(() => computeVisualLines(text, lineWidth), [text, lineWidth]);
622
646
  const { row: cursorVisualRow, col: cursorVisualCol } = cursorToVisual(visualLines, cursor);
647
+ // ---- Wheel-vs-keyboard classification for Up/Down arrows ----
648
+ //
649
+ // With mouse reporting off (so native drag-select/copy works), terminals
650
+ // translate the wheel into Up/Down arrow keys while in the alternate
651
+ // screen. Those synthetic arrows must scroll the transcript, not move the
652
+ // composer cursor or browse history. Three signals, in priority order:
653
+ // 1. kitty keyboard protocol: real key presses carry `eventType`;
654
+ // synthetic wheel arrows are bare legacy sequences. Once one enhanced
655
+ // arrow is seen, bare arrows are classified as wheel with no delay.
656
+ // 2. burst heuristic (non-kitty terminals): a wheel notch delivers
657
+ // several arrows within a few ms; a keypress delivers one. The first
658
+ // arrow is briefly deferred to see whether siblings follow.
659
+ // 3. wheel session: shortly after a wheel burst, single arrows continue
660
+ // to scroll, so slow trackpad scrolling doesn't fall back to history.
661
+ const performVerticalArrowRef = useRef(() => { });
662
+ performVerticalArrowRef.current = (direction) => {
663
+ if (direction === "up") {
664
+ if (cursorVisualRow > 0) {
665
+ setCursor(visualToCursor(visualLines, cursorVisualRow - 1, cursorVisualCol));
666
+ return;
667
+ }
668
+ }
669
+ else {
670
+ if (cursorVisualRow < visualLines.length - 1) {
671
+ setCursor(visualToCursor(visualLines, cursorVisualRow + 1, cursorVisualCol));
672
+ return;
673
+ }
674
+ }
675
+ const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, direction, text);
676
+ if (result.changed) {
677
+ setText(result.text);
678
+ setCursor(result.text.length);
679
+ setHistoryIndex(result.index);
680
+ historyDraftRef.current = result.draft;
681
+ setSelectedIndex(0);
682
+ setPastedContentRefs([]);
683
+ nextPastedContentIndexRef.current = 1;
684
+ }
685
+ };
686
+ const kittyArrowsSeenRef = useRef(false);
687
+ const arrowBurstRef = useRef(null);
688
+ const lastWheelFlushRef = useRef(0);
689
+ const ARROW_BURST_WINDOW_MS = 20;
690
+ const WHEEL_SESSION_MS = 300;
691
+ const flushArrowBurst = (burst) => {
692
+ if (burst.count > 1 && onWheelScroll) {
693
+ lastWheelFlushRef.current = Date.now();
694
+ onWheelScroll(burst.direction, burst.count);
695
+ }
696
+ else {
697
+ performVerticalArrowRef.current(burst.direction);
698
+ }
699
+ };
700
+ const classifyVerticalArrow = (direction, eventType) => {
701
+ if (eventType) {
702
+ kittyArrowsSeenRef.current = true;
703
+ performVerticalArrowRef.current(direction);
704
+ return;
705
+ }
706
+ if (!onWheelScroll) {
707
+ performVerticalArrowRef.current(direction);
708
+ return;
709
+ }
710
+ if (kittyArrowsSeenRef.current) {
711
+ lastWheelFlushRef.current = Date.now();
712
+ onWheelScroll(direction, 1);
713
+ return;
714
+ }
715
+ const pending = arrowBurstRef.current;
716
+ if (pending) {
717
+ if (pending.direction === direction) {
718
+ pending.count += 1;
719
+ return;
720
+ }
721
+ clearTimeout(pending.timer);
722
+ arrowBurstRef.current = null;
723
+ flushArrowBurst(pending);
724
+ }
725
+ if (Date.now() - lastWheelFlushRef.current < WHEEL_SESSION_MS) {
726
+ lastWheelFlushRef.current = Date.now();
727
+ onWheelScroll(direction, 1);
728
+ return;
729
+ }
730
+ const burst = {
731
+ direction,
732
+ count: 1,
733
+ timer: setTimeout(() => {
734
+ arrowBurstRef.current = null;
735
+ flushArrowBurst(burst);
736
+ }, ARROW_BURST_WINDOW_MS),
737
+ };
738
+ arrowBurstRef.current = burst;
739
+ };
740
+ useEffect(() => () => {
741
+ if (arrowBurstRef.current)
742
+ clearTimeout(arrowBurstRef.current.timer);
743
+ }, []);
623
744
  const totalLines = Math.max(visualLines.length, 1);
624
745
  const visibleLines = Math.min(Math.max(totalLines, MIN_VISIBLE_LINES), MAX_VISIBLE_LINES);
625
746
  let scrollOffset = 0;
@@ -694,7 +815,10 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
694
815
  node = node.parentNode;
695
816
  }
696
817
  const rootHeight = lastNode?.yogaNode?.getComputedHeight() ?? 0;
697
- const viewportRows = stdout.rows ?? process.stdout.rows ?? 24;
818
+ // `||` on purpose: some ptys (and Bun on a detached tty) report rows as 0,
819
+ // which `??` would happily accept — and `rootHeight >= 0` then flags every
820
+ // frame as fullscreen, forcing a bogus +1 row compensation.
821
+ const viewportRows = stdout.rows || process.stdout.rows || 24;
698
822
  const previousOutputHeight = previousOutputHeightRef.current;
699
823
  // After a clear/sync frame, Ink's physical terminal cursor remains on the
700
824
  // last rendered row even though log-update records an output string with a
@@ -761,12 +885,18 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch =
761
885
  const isFirst = visualIdx === 0;
762
886
  const isCursorLine = visualIdx === cursorVisualRow;
763
887
  const prompt = isFirst ? PROMPT : " ".repeat(PROMPT.length);
764
- const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(lineText)));
888
+ const cursorSegments = isCursorLine && !disabled
889
+ ? splitLineAtCursor(lineText, cursor - (visualLines[cursorVisualRow]?.absStart ?? 0))
890
+ : null;
891
+ const renderedLine = cursorSegments
892
+ ? cursorSegments.before + cursorSegments.at + cursorSegments.after
893
+ : lineText;
894
+ const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(renderedLine)));
765
895
  return (_jsxs(Box, { height: 1, overflow: "hidden", ref: isCursorLine
766
896
  ? (el) => {
767
897
  cursorLineRef.current = el;
768
898
  }
769
- : undefined, children: [_jsx(Text, { backgroundColor: inputBg, color: isFirst ? theme.accent : theme.inputText, children: prompt }), _jsx(Text, { backgroundColor: inputBg, color: theme.inputText, children: lineText }), _jsx(Text, { backgroundColor: inputBg, children: fill })] }, visualIdx));
899
+ : undefined, children: [_jsx(Text, { backgroundColor: inputBg, color: isFirst ? theme.accent : theme.inputText, children: prompt }), cursorSegments ? (_jsxs(_Fragment, { children: [cursorSegments.before && (_jsx(Text, { backgroundColor: inputBg, color: theme.inputText, children: cursorSegments.before })), _jsx(Text, { backgroundColor: theme.inputText, color: inputBg, children: cursorSegments.at }), cursorSegments.after && (_jsx(Text, { backgroundColor: inputBg, color: theme.inputText, children: cursorSegments.after }))] })) : (_jsx(Text, { backgroundColor: inputBg, color: theme.inputText, children: lineText })), _jsx(Text, { backgroundColor: inputBg, children: fill })] }, visualIdx));
770
900
  }), hasMoreBelow && (_jsx(Text, { backgroundColor: inputBg, color: theme.muted, dimColor: true, children: filledLine(` ↓ ${moreBelow} more`) }))] }), showSuggestions && mode === "slash" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 4, children: [slashSuggestions
771
901
  .slice(suggestionOffset, suggestionOffset + MAX_VISIBLE_SUGGESTIONS)
772
902
  .map((cmd, visibleIndex) => {
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { DisplayMessage, DisplayMessagePart, DisplayToolCall } from "./display-history.js";
2
+ import { type DisplayMessage, type DisplayMessagePart, type DisplayToolCall } from "./display-history.js";
3
3
  /**
4
4
  * Hint surfaced when the user can interrupt the currently-running pending tool
5
5
  * via the approval dialog. The match is loose (by request type → tool name),
@@ -1,13 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { Box, Static, Text } from "ink";
3
+ import { Box, Text } from "ink";
4
4
  import { useTheme } from "./theme.js";
5
5
  import { highlightCode, inferLang } from "./code-highlight.js";
6
6
  import { MarkdownContent, StreamingMarkdown } from "./markdown.js";
7
- import { buildTraceGroups, formatTracePath, traceGroupLabel } from "./trace-groups.js";
7
+ import { userInputStatusBadgeLabel, } from "./display-history.js";
8
+ import { buildTraceGroups, executeCommandBlock, formatTracePath, shouldInlineExecuteCommand, traceGroupLabel, } from "./trace-groups.js";
8
9
  import { EDIT_COLLAPSED_DIFF_LINES, formatEditSuccessSummary, getEditDiffDetails } from "./edit-diff.js";
9
10
  import { formatSubagentRoute } from "../agent/subagent-route-format.js";
10
11
  import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
12
+ const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
11
13
  export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, verboseTrace, pendingApproval, nowTick, welcomeBanner, }) {
12
14
  const hasStreaming = !!(streamingContent ||
13
15
  streamingReasoning ||
@@ -27,17 +29,22 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
27
29
  showExpandHint: !hasStreaming && i === lastMessageIndex,
28
30
  });
29
31
  }
30
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Static, { items: staticItems, children: (item) => {
31
- if (item.kind === "welcome") {
32
- return _jsx(React.Fragment, { children: welcomeBanner }, item.key);
33
- }
34
- return (_jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, nowTick: item.showExpandHint ? nowTick : undefined }, item.key));
35
- } }), hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick }))] }));
36
- }
37
- function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, nowTick, }) {
32
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [staticItems.map((item) => {
33
+ if (item.kind === "welcome") {
34
+ return _jsx(React.Fragment, { children: welcomeBanner }, item.key);
35
+ }
36
+ return (_jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, nowTick: item.showExpandHint ? nowTick : undefined }, item.key));
37
+ }), hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick }))] }));
38
+ }
39
+ // Memoized: with no <Static> region, every transcript row re-renders on each
40
+ // state change unless its props are referentially stable. Message objects are
41
+ // append-only (compaction reuses already-compacted instances), keys are
42
+ // stable, and nowTick is only threaded to the last row, so memo hits for all
43
+ // settled history rows.
44
+ const MessageItem = React.memo(function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, nowTick, }) {
38
45
  const theme = useTheme();
39
46
  if (message.role === "user") {
40
- return _jsx(UserMessageBlock, { content: message.content, terminalColumns: terminalColumns });
47
+ return (_jsx(UserMessageBlock, { content: message.content, terminalColumns: terminalColumns, inputStatus: message.inputStatus }));
41
48
  }
42
49
  if (message.role === "error") {
43
50
  return (_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { color: theme.error, children: ["Error: ", message.content] }) }));
@@ -45,6 +52,9 @@ function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, n
45
52
  if (message.syntheticKind === "ui_compact_summary") {
46
53
  return _jsx(CompactionSummaryBlock, { message: message });
47
54
  }
55
+ if (message.syntheticKind === "ui_interrupt") {
56
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.error, children: "\u23F9 " }), _jsx(Text, { color: theme.muted, dimColor: true, children: message.content || "Interrupted by user" })] }));
57
+ }
48
58
  const visibleReasoning = sanitizeInternalReminderBlocks(message.reasoning ?? "").trim();
49
59
  const hasVisibleAssistantContent = !!message.content ||
50
60
  (message.toolCalls?.length ?? 0) > 0 ||
@@ -53,7 +63,7 @@ function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, n
53
63
  if (!hasVisibleAssistantContent)
54
64
  return null;
55
65
  return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && verboseTrace && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), message.content && _jsx(MarkdownContent, { content: message.content })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
56
- }
66
+ });
57
67
  function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, verboseTrace, pendingApproval, nowTick, }) {
58
68
  const deferredContent = React.useDeferredValue(content);
59
69
  const deferredReasoning = React.useDeferredValue(reasoning);
@@ -63,14 +73,12 @@ function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, v
63
73
  ? deferredParts
64
74
  : fallbackStreamingParts(deferredContent, tools);
65
75
  return (_jsxs(Box, { flexDirection: "column", children: [visibleReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }) })), visibleParts.length > 0 && (
66
- // marginTop intentionally 0: this Box only mounts on the first non-empty
67
- // streaming frame, so a marginTop=1 here would visibly insert a blank
68
- // line under the user message right at that moment (the "spinner sits
69
- // close, then content appears with a sudden gap, then spinner slides
70
- // down" effect users perceive as flicker on the DOM xterm renderer).
71
- // marginBottom=1 stays so streamed text doesn't collide with the
72
- // WaitingIndicator rendered below.
73
- _jsx(Box, { marginTop: 0, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
76
+ // marginTop=1 matches the committed MessageItem layout exactly, so the
77
+ // gap under the user message is identical while streaming and after the
78
+ // turn commits no spacing jump at finalize time. (The old marginTop=0
79
+ // was a flicker mitigation for the main-screen <Static> renderer; the
80
+ // alt-screen viewport repaints frames atomically, so it's obsolete.)
81
+ _jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
74
82
  }
75
83
  function MessageParts({ parts, terminalColumns, verboseTrace, pendingApproval, showExpandHint, nowTick, showActivity = false, streaming = false, }) {
76
84
  const lastToolsPartIndex = findLastToolsPartIndex(parts);
@@ -157,14 +165,22 @@ function TraceGroupBlock({ group, terminalColumns, pendingApproval, compactTop,
157
165
  const commandWidth = Math.max(14, terminalColumns - group.title.length - 20);
158
166
  const detailWidth = Math.max(20, terminalColumns - 8);
159
167
  const detailLines = group.previewLines.length > 0 ? group.previewLines : group.items;
160
- // When a bash command is too long to fit on the title line, drop it onto its
161
- // own indented rows so narrow splits keep the full command visible instead of
162
- // silently truncating mid-flag.
163
- const commandFitsInline = !group.command || visualWidth(group.command) <= commandWidth;
164
- const wrappedCommandLines = group.command && !commandFitsInline
165
- ? wrapByVisualWidth(group.command, Math.max(10, detailWidth - 2))
168
+ // A model-provided bash description owns the header slot; the command then
169
+ // always renders as a block below. Without one, single-line commands that
170
+ // fit stay inline; anything longer becomes a wrapped block preserving the
171
+ // command's own line structure commands are never clipped mid-line.
172
+ const showDescription = group.kind === "execute" && !!group.description;
173
+ const inlineCommand = !showDescription && group.command
174
+ ? (group.kind === "execute"
175
+ ? (shouldInlineExecuteCommand(group, commandWidth) ? group.command : undefined)
176
+ : (visualWidth(group.command) <= commandWidth ? group.command : undefined))
177
+ : undefined;
178
+ const commandBlock = group.command && !inlineCommand
179
+ ? (group.kind === "execute"
180
+ ? executeCommandBlock(group, EXECUTE_COMMAND_BLOCK_MAX_LINES)
181
+ : { lines: [group.command], omitted: 0 })
166
182
  : null;
167
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: titleColor, children: group.title }), group.command && commandFitsInline ? (_jsxs(Text, { color: theme.traceCommand, children: [" ", group.command] })) : !group.command && group.count !== undefined && group.noun ? (_jsxs(Text, { color: theme.traceCount, children: [" ", group.count, " ", group.noun] })) : null, status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), wrappedCommandLines && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: wrappedCommandLines.map((seg, idx) => (_jsx(Text, { color: theme.traceCommand, children: seg }, `cmd-${idx}`))) })), detailLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: detailLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: detailColor, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, index))) })), group.errorLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: group.errorLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: theme.error, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, `error-${index}`))) })), group.omitted > 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["... ", group.omitted, " more, Ctrl+O to view"] }) }))] }));
183
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: titleColor, children: group.title }), showDescription ? (_jsxs(Text, { color: theme.traceDetail, children: [" ", truncateVisual(group.description, commandWidth)] })) : inlineCommand ? (_jsxs(Text, { color: theme.traceCommand, children: [" ", inlineCommand] })) : !group.command && group.count !== undefined && group.noun ? (_jsxs(Text, { color: theme.traceCount, children: [" ", group.count, " ", group.noun] })) : null, status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), commandBlock && commandBlock.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [commandBlock.lines.flatMap((line, idx) => wrapByVisualWidth(line || " ", Math.max(10, detailWidth - 2)).map((seg, segIdx) => (_jsx(Text, { color: theme.traceCommand, children: seg }, `cmd-${idx}-${segIdx}`)))), commandBlock.omitted > 0 && (_jsxs(Text, { color: theme.traceDetail, children: ["\u2026 ", commandBlock.omitted, " more line", commandBlock.omitted === 1 ? "" : "s"] }))] })), detailLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: detailLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: detailColor, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, index))) })), group.errorLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: group.errorLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: theme.error, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, `error-${index}`))) })), group.omitted > 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["... ", group.omitted, " more, Ctrl+O to view"] }) }))] }));
168
184
  }
169
185
  function EditTraceBlock({ tool, details, terminalColumns, compactTop, status, }) {
170
186
  const theme = useTheme();
@@ -220,8 +236,9 @@ function CompactionSummaryBlock({ message }) {
220
236
  const summary = message.compactionSummary?.trim();
221
237
  return (_jsxs(Box, { marginTop: 1, marginBottom: 1, paddingX: 1, flexDirection: "column", borderStyle: "round", borderColor: theme.borderActive, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.success, bold: true, children: "\u2713 " }), _jsx(Text, { color: theme.accent, bold: true, children: "Compaction checkpoint" }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", status] })] }), summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.muted, dimColor: true, children: "Preserved context summary" }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: _jsx(MarkdownContent, { content: summary }) })] }))] }));
222
238
  }
223
- function UserMessageBlock({ content, terminalColumns }) {
239
+ function UserMessageBlock({ content, terminalColumns, inputStatus, }) {
224
240
  const theme = useTheme();
241
+ const badge = userInputStatusBadgeLabel(inputStatus);
225
242
  // Rail and its right gutter must share the bubble background; otherwise the
226
243
  // terminal background shows up as a dark seam between rail and message.
227
244
  const railWidth = 2;
@@ -230,7 +247,7 @@ function UserMessageBlock({ content, terminalColumns }) {
230
247
  const wrappedLines = content
231
248
  .split("\n")
232
249
  .flatMap((line) => wrapByVisualWidth(line, bubbleTextWidth));
233
- return (_jsx(Box, { flexDirection: "column", children: wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userRail, children: index === 0 ? "▌ " : " " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))) }));
250
+ return (_jsxs(Box, { flexDirection: "column", children: [badge && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: inputStatus === "pending_steer" ? theme.warning : theme.muted, children: ` ${badge} ` }), _jsx(Text, { color: theme.dim, children: inputStatus === "pending_steer" ? "applies at the next model call" : "runs after this turn" })] })), wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userRail, children: index === 0 ? "▌ " : " " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index)))] }));
234
251
  }
235
252
  const TOOL_DISPLAY_NAMES = {
236
253
  read: "Read",
@@ -4,13 +4,14 @@ import type { SessionManager } from "../session.js";
4
4
  import type { Provider } from "../types.js";
5
5
  import type { ProviderRegistry } from "../provider-registry.js";
6
6
  import type { SkillRegistry } from "../skills/registry.js";
7
- import { type ApprovalHandlerRef, type PlanHandlerRef } from "./app.js";
7
+ import { type ApprovalHandlerRef, type ExitSummary, type PlanHandlerRef } from "./app.js";
8
8
  import type { BashAllowlist } from "../approval/session-cache.js";
9
9
  import type { SettingsManager } from "../permissions/settings.js";
10
10
  import type { McpManager } from "../mcp/manager.js";
11
11
  import type { LspService } from "../lsp/index.js";
12
12
  import type { QuestionController } from "../question/index.js";
13
13
  import type { MemoryScope } from "../memory/index.js";
14
+ import type { ExternalHookController } from "../hooks/controller.js";
14
15
  import type { ResolvedTheme, ThemeMode } from "./theme.js";
15
16
  export interface RunTuiOptions {
16
17
  sessionManager?: SessionManager;
@@ -33,5 +34,9 @@ export interface RunTuiOptions {
33
34
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
34
35
  runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
35
36
  bypassEnabled?: boolean;
37
+ /** One-line "update available" notice rendered under the welcome banner version. */
38
+ updateNotice?: string;
39
+ /** External lifecycle hooks, threaded into slash-command execution. */
40
+ hookController?: ExternalHookController;
36
41
  }
37
- export declare function runTui(agent: Agent, args: CliArgs, options?: RunTuiOptions): Promise<void>;
42
+ export declare function runTui(agent: Agent, args: CliArgs, options?: RunTuiOptions): Promise<ExitSummary | undefined>;