@bubblebrain-ai/bubble 0.0.36 → 0.0.38

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 (52) hide show
  1. package/dist/agent/internal-reminder-sanitizer.js +2 -0
  2. package/dist/agent/task-lifecycle-reminder.d.ts +26 -0
  3. package/dist/agent/task-lifecycle-reminder.js +59 -0
  4. package/dist/agent.d.ts +26 -0
  5. package/dist/agent.js +39 -0
  6. package/dist/approval/controller.d.ts +3 -1
  7. package/dist/approval/controller.js +5 -2
  8. package/dist/approval/tool-helper.js +10 -1
  9. package/dist/approval/types.d.ts +15 -0
  10. package/dist/config.d.ts +6 -0
  11. package/dist/config.js +4 -0
  12. package/dist/loop/engine.d.ts +29 -0
  13. package/dist/loop/engine.js +63 -0
  14. package/dist/main.js +29 -0
  15. package/dist/orchestrator/default-hooks.js +16 -0
  16. package/dist/session-types.d.ts +1 -1
  17. package/dist/tasks/manager.d.ts +136 -0
  18. package/dist/tasks/manager.js +530 -0
  19. package/dist/tasks/process-utils.d.ts +10 -0
  20. package/dist/tasks/process-utils.js +49 -0
  21. package/dist/tasks/promotion.d.ts +17 -0
  22. package/dist/tasks/promotion.js +30 -0
  23. package/dist/tasks/wake.d.ts +53 -0
  24. package/dist/tasks/wake.js +125 -0
  25. package/dist/tools/bash.d.ts +15 -1
  26. package/dist/tools/bash.js +103 -3
  27. package/dist/tools/child-tools.js +3 -1
  28. package/dist/tools/edit.js +4 -12
  29. package/dist/tools/index.d.ts +13 -0
  30. package/dist/tools/index.js +8 -1
  31. package/dist/tools/server-manager.d.ts +6 -36
  32. package/dist/tools/server-manager.js +6 -234
  33. package/dist/tools/task-tools.d.ts +10 -0
  34. package/dist/tools/task-tools.js +129 -0
  35. package/dist/tools/write.js +6 -12
  36. package/dist/tui-ink/app.d.ts +20 -2
  37. package/dist/tui-ink/app.js +309 -21
  38. package/dist/tui-ink/approval/approval-dialog.js +4 -4
  39. package/dist/tui-ink/display-history.d.ts +1 -1
  40. package/dist/tui-ink/footer.d.ts +3 -3
  41. package/dist/tui-ink/footer.js +3 -3
  42. package/dist/tui-ink/input-box.d.ts +6 -0
  43. package/dist/tui-ink/markdown.d.ts +10 -2
  44. package/dist/tui-ink/markdown.js +13 -12
  45. package/dist/tui-ink/message-list.js +18 -7
  46. package/dist/tui-ink/model-picker.js +33 -10
  47. package/dist/tui-ink/run.d.ts +6 -0
  48. package/dist/tui-ink/run.js +1 -1
  49. package/dist/tui-ink/subagent-inspector.d.ts +5 -1
  50. package/dist/tui-ink/subagent-inspector.js +23 -7
  51. package/dist/tui-ink/welcome.js +4 -0
  52. package/package.json +1 -1
@@ -344,7 +344,7 @@ function InlineText({ text, maxWidth }) {
344
344
  const wrapped = wrapInlineSegments(parseMarkdownInlineSegments(text), effectiveWidth);
345
345
  return (_jsx(_Fragment, { children: wrapped.map((segments, li) => (_jsx(Text, { children: segments.length ? renderSegmentNodes(segments, `inline-${li}`, theme.inlineCode) : " " }, li))) }));
346
346
  }
347
- function CodeBlock({ lang, lines }) {
347
+ function CodeBlock({ lang, lines, flushTop = false }) {
348
348
  const theme = useTheme();
349
349
  // Lazy init: try sync highlight when shiki is already warm so the very first
350
350
  // paint carries highlighted output. Fall back to raw lines if shiki hasn't
@@ -376,9 +376,9 @@ function CodeBlock({ lang, lines }) {
376
376
  cancelled = true;
377
377
  };
378
378
  }, [lang, lines]);
379
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [lang && _jsx(Text, { color: theme.muted, children: lang }), _jsx(Box, { flexDirection: "column", children: highlighted.map((line, i) => (_jsx(Text, { children: line || " " }, i))) })] }));
379
+ return (_jsxs(Box, { flexDirection: "column", marginTop: flushTop ? 0 : 1, marginBottom: 1, children: [lang && _jsx(Text, { color: theme.muted, children: lang }), _jsx(Box, { flexDirection: "column", children: highlighted.map((line, i) => (_jsx(Text, { children: line || " " }, i))) })] }));
380
380
  }
381
- function TableBlock({ headers, rows, maxWidth, }) {
381
+ function TableBlock({ headers, rows, maxWidth, flushTop = false, }) {
382
382
  const theme = useTheme();
383
383
  const { columns: termWidth } = useTerminalSize();
384
384
  const colCount = headers.length;
@@ -429,7 +429,7 @@ function TableBlock({ headers, rows, maxWidth, }) {
429
429
  const height = Math.max(1, ...wrapped.map((lines) => lines.length));
430
430
  return (_jsx(Box, { flexDirection: "column", children: Array.from({ length: height }, (_, line) => (_jsxs(Text, { children: [`${g.v} `, wrapped.map((cellLines, i) => (_jsxs(React.Fragment, { children: [renderCellLine(cellLines[line] ?? [], widths[i] ?? 4, `${keyPrefix}-cell-${i}-${line}`, theme.inlineCode), i < colCount - 1 ? ` ${g.v} ` : ` ${g.v}`] }, i)))] }, `${keyPrefix}-line-${line}`))) }, keyPrefix));
431
431
  };
432
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { children: top }), renderRow(headers, "header", true), _jsx(Text, { children: mid }), rows.map((row, ri) => renderRow(row, `row-${ri}`)), _jsx(Text, { children: bot })] }));
432
+ return (_jsxs(Box, { flexDirection: "column", marginTop: flushTop ? 0 : 1, marginBottom: 1, children: [_jsx(Text, { children: top }), renderRow(headers, "header", true), _jsx(Text, { children: mid }), rows.map((row, ri) => renderRow(row, `row-${ri}`)), _jsx(Text, { children: bot })] }));
433
433
  }
434
434
  /** Cap a pathological cell (huge blob of text) at this many wrapped lines. */
435
435
  const MAX_TABLE_CELL_LINES = 8;
@@ -663,7 +663,7 @@ export function wrapInlineSegments(segments, maxWidth) {
663
663
  function inlineSegmentsWidth(segments) {
664
664
  return segments.reduce((sum, segment) => sum + visualWidth(segment.text), 0);
665
665
  }
666
- function HeadingBlock({ level, text }) {
666
+ function HeadingBlock({ level, text, flushTop = false }) {
667
667
  const theme = useTheme();
668
668
  const props = { bold: true };
669
669
  if (level === 1) {
@@ -676,7 +676,7 @@ function HeadingBlock({ level, text }) {
676
676
  else if (level === 3) {
677
677
  props.color = theme.warning;
678
678
  }
679
- return (_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { ...props, children: text }) }));
679
+ return (_jsx(Box, { marginTop: flushTop ? 0 : 1, marginBottom: 1, children: _jsx(Text, { ...props, children: text }) }));
680
680
  }
681
681
  /**
682
682
  * Streaming-aware wrapper around `MarkdownContent`.
@@ -693,7 +693,7 @@ function HeadingBlock({ level, text }) {
693
693
  * reset handles the rare case where `content` is replaced wholesale (e.g.,
694
694
  * the user re-enters a turn).
695
695
  */
696
- export function StreamingMarkdown({ content, maxWidth, }) {
696
+ export function StreamingMarkdown({ content, maxWidth, flushTop = false, }) {
697
697
  const stablePrefixRef = React.useRef("");
698
698
  if (!content.startsWith(stablePrefixRef.current)) {
699
699
  stablePrefixRef.current = "";
@@ -706,7 +706,7 @@ export function StreamingMarkdown({ content, maxWidth, }) {
706
706
  }
707
707
  const stablePrefix = stablePrefixRef.current;
708
708
  const unstableSuffix = content.substring(stablePrefix.length);
709
- return (_jsxs(Box, { flexDirection: "column", children: [stablePrefix && _jsx(MarkdownContent, { content: stablePrefix, maxWidth: maxWidth }), unstableSuffix && _jsx(MarkdownContent, { content: unstableSuffix, maxWidth: maxWidth })] }));
709
+ return (_jsxs(Box, { flexDirection: "column", children: [stablePrefix && _jsx(MarkdownContent, { content: stablePrefix, maxWidth: maxWidth, flushTop: flushTop }), unstableSuffix && _jsx(MarkdownContent, { content: unstableSuffix, maxWidth: maxWidth, flushTop: flushTop && !stablePrefix })] }));
710
710
  }
711
711
  // A markdown list item: optional indent, a bullet (-, *, +) or ordered marker
712
712
  // (1. / 2)), then whitespace, then the item text. The captured prefix is
@@ -720,17 +720,18 @@ export function splitListItem(line) {
720
720
  const prefix = m[1] + m[2] + m[3];
721
721
  return { prefix, content: m[4], indent: visualWidth(prefix) };
722
722
  }
723
- export function MarkdownContent({ content, maxWidth, }) {
723
+ export function MarkdownContent({ content, maxWidth, flushTop = false, }) {
724
724
  const blocks = React.useMemo(() => parseMarkdownBlocks(content), [content]);
725
725
  return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => {
726
+ const blockFlushTop = flushTop && i === 0;
726
727
  if (block.type === "code") {
727
- return _jsx(CodeBlock, { lang: block.lang, lines: block.lines }, i);
728
+ return _jsx(CodeBlock, { lang: block.lang, lines: block.lines, flushTop: blockFlushTop }, i);
728
729
  }
729
730
  if (block.type === "table") {
730
- return (_jsx(TableBlock, { headers: block.headers, rows: block.rows, maxWidth: maxWidth }, i));
731
+ return (_jsx(TableBlock, { headers: block.headers, rows: block.rows, maxWidth: maxWidth, flushTop: blockFlushTop }, i));
731
732
  }
732
733
  if (block.type === "heading") {
733
- return _jsx(HeadingBlock, { level: block.level, text: block.text }, i);
734
+ return _jsx(HeadingBlock, { level: block.level, text: block.text, flushTop: blockFlushTop }, i);
734
735
  }
735
736
  return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: block.lines.map((line, li) => {
736
737
  const item = splitListItem(line);
@@ -31,8 +31,14 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
31
31
  const regularMessages = messages.filter((message) => !message.inputStatus);
32
32
  const pendingSteerMessages = messages.filter((message) => message.inputStatus === "pending_steer");
33
33
  const queuedInputMessages = messages.filter((message) => message.inputStatus === "queued");
34
+ // While the transcript has no settled rows, the banner stays OUT of
35
+ // <Static> and renders in the live region instead, so it repaints on every
36
+ // commit — a /model switch on the fresh launch screen updates the banner in
37
+ // place. It is committed to scrollback together with the first settled row
38
+ // (same render commit, so the hand-off is seamless) and frozen from then on.
39
+ const bannerCommitted = regularMessages.length > 0;
34
40
  const staticItems = [];
35
- if (welcomeBanner) {
41
+ if (welcomeBanner && bannerCommitted) {
36
42
  staticItems.push({ kind: "welcome", key: "welcome" });
37
43
  }
38
44
  const lastMessageIndex = regularMessages.length - 1;
@@ -55,10 +61,10 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
55
61
  const clampDynamic = typeof maxStreamRows === "number" && maxStreamRows > 0;
56
62
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Static, { items: staticItems, children: (item) => {
57
63
  if (item.kind === "welcome") {
58
- return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: welcomeBanner }, item.key));
64
+ return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, backgroundColor: theme.background, children: welcomeBanner }, item.key));
59
65
  }
60
- return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: _jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, showThinking: showThinking, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, separateFromPrevious: item.separateFromPrevious }) }, item.key));
61
- } }, `transcript-${staticGeneration}`), hasDynamic && (_jsxs(DynamicClamp, { maxRows: clampDynamic ? maxStreamRows : undefined, paddingX: paddingX, children: [hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, showThinking: showThinking, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick })), pendingSteerMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: pendingSteerMessages, terminalColumns: terminalColumns, title: "Messages to steer at next model call", hint: "applies before the next provider request", bulletColor: theme.warning })), queuedInputMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: queuedInputMessages, terminalColumns: terminalColumns, title: "Messages queued for next turn", hint: "runs after the current answer", bulletColor: theme.muted }))] }))] }));
66
+ return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, backgroundColor: theme.background, children: _jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, showThinking: showThinking, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, separateFromPrevious: item.separateFromPrevious }) }, item.key));
67
+ } }, `transcript-${staticGeneration}`), welcomeBanner && !bannerCommitted && (_jsx(Box, { flexDirection: "column", paddingX: paddingX, backgroundColor: theme.background, children: welcomeBanner })), hasDynamic && (_jsxs(DynamicClamp, { maxRows: clampDynamic ? maxStreamRows : undefined, paddingX: paddingX, backgroundColor: theme.background, children: [hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, showThinking: showThinking, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick })), pendingSteerMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: pendingSteerMessages, terminalColumns: terminalColumns, title: "Messages to steer at next model call", hint: "applies before the next provider request", bulletColor: theme.warning })), queuedInputMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: queuedInputMessages, terminalColumns: terminalColumns, title: "Messages queued for next turn", hint: "runs after the current answer", bulletColor: theme.muted }))] }))] }));
62
68
  }
63
69
  /**
64
70
  * Bounds the live (in-progress turn) region to at most `maxRows` rows, pinned
@@ -70,7 +76,7 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
70
76
  * (no reserved-space gap). The full turn still lands in <Static> scrollback the
71
77
  * moment it commits, so nothing is lost — only the live preview is windowed.
72
78
  */
73
- function DynamicClamp({ maxRows, paddingX, children, }) {
79
+ function DynamicClamp({ maxRows, paddingX, backgroundColor, children, }) {
74
80
  const innerRef = React.useRef(null);
75
81
  const [offset, setOffset] = React.useState(0);
76
82
  const [clipHeight, setClipHeight] = React.useState(undefined);
@@ -102,7 +108,7 @@ function DynamicClamp({ maxRows, paddingX, children, }) {
102
108
  setClipHeight(undefined);
103
109
  }
104
110
  });
105
- return (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingX: paddingX, ...(clipHeight !== undefined ? { height: clipHeight, overflowY: "hidden" } : {}), children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: offset, children: children }) }));
111
+ return (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingX: paddingX, backgroundColor: backgroundColor, ...(clipHeight !== undefined ? { height: clipHeight, overflowY: "hidden" } : {}), children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: offset, children: children }) }));
106
112
  }
107
113
  // Memoized: with no <Static> region, every transcript row re-renders on each
108
114
  // state change unless its props are referentially stable. Message objects are
@@ -123,6 +129,11 @@ const MessageItem = React.memo(function MessageItem({ message, terminalColumns,
123
129
  if (message.syntheticKind === "ui_interrupt") {
124
130
  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" })] }));
125
131
  }
132
+ // One-line status notices (model switch etc.): accent-colored so they read
133
+ // as UI feedback, not assistant prose.
134
+ if (message.syntheticKind === "ui_notice") {
135
+ return (_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.accent, children: ["\u2726 ", message.content] }) }));
136
+ }
126
137
  const visibleReasoning = sanitizeInternalReminderBlocks(message.reasoning ?? "").trim();
127
138
  // Same defense as reasoning: strip any internal reminder markup the model
128
139
  // echoed back into its visible answer so it never reaches the transcript.
@@ -209,7 +220,7 @@ function TimelineText({ content, compactTop, terminalColumns, streaming = false,
209
220
  ? Math.max(20, terminalColumns - gutter - TRANSCRIPT_ROW_PADDING)
210
221
  : undefined;
211
222
  const trimmed = visible.trim();
212
- return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: streaming ? (_jsx(StreamingMarkdown, { content: trimmed, maxWidth: available })) : (_jsx(MarkdownContent, { content: trimmed, maxWidth: available })) })] }));
223
+ return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: streaming ? (_jsx(StreamingMarkdown, { content: trimmed, maxWidth: available, flushTop: true })) : (_jsx(MarkdownContent, { content: trimmed, maxWidth: available, flushTop: true })) })] }));
213
224
  }
214
225
  function ToolsPart({ toolCalls, terminalColumns, verboseTrace, pendingApproval, showExpandHint, compactTop = false, nowTick, showActivity = false, }) {
215
226
  if (toolCalls.length === 0)
@@ -22,10 +22,17 @@ export function resolvePickerKeyAction(input, key) {
22
22
  return "backspace";
23
23
  if (key.delete)
24
24
  return "delete";
25
+ // Batched chunks: slow ptys (web-terminal bridges, SSH, paste) can deliver
26
+ // several keys in ONE input event, in which case ink sets no key flags at
27
+ // all ("\r/quit\r" arrives with return=false). Act on the chunk's leading
28
+ // key instead of swallowing it — exact-match anchors would drop the whole
29
+ // chunk.
30
+ if (input.startsWith("\r") || input.startsWith("\n"))
31
+ return "enter";
25
32
  const sequence = normalizeEscapeSequence(input);
26
- if (/^(?:O|\[[\d;:]*)A$/.test(sequence))
33
+ if (/^(?:O|\[[\d;:]*)A/.test(sequence))
27
34
  return "up";
28
- if (/^(?:O|\[[\d;:]*)B$/.test(sequence))
35
+ if (/^(?:O|\[[\d;:]*)B/.test(sequence))
29
36
  return "down";
30
37
  return undefined;
31
38
  }
@@ -793,6 +800,16 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title,
793
800
  const idx = providers.findIndex((p) => p.id === current);
794
801
  return idx >= 0 ? idx : 0;
795
802
  });
803
+ // Authoritative selection lives in a ref, mutated synchronously by the
804
+ // input handler; state only mirrors it for display. Slow ptys (web-terminal
805
+ // bridges) can deliver several key events before React commits a re-render,
806
+ // and a state-only selection would leave the handler closure stale — Enter
807
+ // would then select the pre-batch highlight.
808
+ const selectedIndexRef = useRef(selectedIndex);
809
+ const moveSelection = (next) => {
810
+ selectedIndexRef.current = next;
811
+ setSelectedIndex(next);
812
+ };
796
813
  useEffect(() => {
797
814
  const p = providers[selectedIndex];
798
815
  if (p)
@@ -807,30 +824,31 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title,
807
824
  return;
808
825
  }
809
826
  if (action === "enter") {
810
- const p = providers[selectedIndex];
827
+ const p = providers[selectedIndexRef.current];
811
828
  if (p)
812
829
  onSelect(p.id);
813
830
  return;
814
831
  }
815
832
  if (action === "up") {
816
- setSelectedIndex((i) => Math.max(0, i - 1));
833
+ moveSelection((selectedIndexRef.current - 1 + providers.length) % providers.length);
817
834
  return;
818
835
  }
819
836
  if (action === "down") {
820
- setSelectedIndex((i) => Math.min(providers.length - 1, i + 1));
837
+ moveSelection((selectedIndexRef.current + 1) % providers.length);
821
838
  return;
822
839
  }
823
840
  if (isPrintablePickerInput(input) && input.length === 1 && /[a-z]/i.test(input)) {
824
841
  const char = input.toLowerCase();
825
- for (let i = selectedIndex + 1; i < providers.length; i++) {
842
+ const from = selectedIndexRef.current;
843
+ for (let i = from + 1; i < providers.length; i++) {
826
844
  if (providers[i].name.toLowerCase().startsWith(char)) {
827
- setSelectedIndex(i);
845
+ moveSelection(i);
828
846
  return;
829
847
  }
830
848
  }
831
- for (let i = 0; i <= selectedIndex; i++) {
849
+ for (let i = 0; i <= from; i++) {
832
850
  if (providers[i].name.toLowerCase().startsWith(char)) {
833
- setSelectedIndex(i);
851
+ moveSelection(i);
834
852
  return;
835
853
  }
836
854
  }
@@ -838,7 +856,12 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title,
838
856
  });
839
857
  const start = Math.max(0, Math.min(selectedIndex, providers.length - maxVisible));
840
858
  const visible = providers.slice(start, start + maxVisible);
841
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: title || "Select Provider" }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel \u00B7 type letter to jump" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((p, i) => {
859
+ return (
860
+ // backgroundColor lives on the picker's OWN root box, below its state:
861
+ // a canvas toggle (theme preview across a forced-theme mismatch) then
862
+ // reshapes only this stateless output subtree. On an ancestor wrapper it
863
+ // would remount the whole picker and reset selectedIndex mid-navigation.
864
+ _jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, backgroundColor: theme.background, children: [_jsx(Text, { bold: true, color: theme.accent, children: title || "Select Provider" }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel \u00B7 type letter to jump" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((p, i) => {
842
865
  const actualIndex = start + i;
843
866
  const isSelected = actualIndex === selectedIndex;
844
867
  return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.accent : undefined, children: [isSelected ? "> " : " ", p.name, p.id === current ? " (current)" : "", !p.enabled ? " [disabled]" : ""] }) }, p.id));
@@ -34,6 +34,12 @@ export interface RunTuiOptions {
34
34
  mcpManager?: McpManager;
35
35
  /** Shared with the model-facing goal tools and the Ink auto-continuation loop. */
36
36
  goalStore?: import("../goal/store.js").GoalStore;
37
+ /** Unified process manager; drives task notices, auto-resume, and the status row. */
38
+ processManager?: import("../tasks/manager.js").ProcessManager;
39
+ /** tasks.auto_resume from user config (design §2.3b). Default ON. */
40
+ tasksAutoResume?: boolean;
41
+ /** Ctrl+G send-to-background channel shared with the bash tool (design §2.5). */
42
+ promotionChannel?: import("../tasks/promotion.js").PromotionChannel;
37
43
  themeMode?: ThemeMode;
38
44
  themeOverrides?: Record<string, string>;
39
45
  detectedTheme?: ResolvedTheme;
@@ -3,7 +3,7 @@ import { render } from "ink";
3
3
  import { App } from "./app.js";
4
4
  import { warmHighlighter } from "./code-highlight.js";
5
5
  export function createInkAppElement(agent, args, options, onExit) {
6
- return (_jsx(App, { agent: agent, args: args, sessionManager: options.sessionManager, switchSession: options.switchSession, createProvider: options.createProvider, registry: options.registry, skillRegistry: options.skillRegistry, planHandlerRef: options.planHandlerRef, approvalHandlerRef: options.approvalHandlerRef, questionController: options.questionController, bashAllowlist: options.bashAllowlist, settingsManager: options.settingsManager, lspService: options.lspService, mcpManager: options.mcpManager, goalStore: options.goalStore, themeMode: options.themeMode, themeOverrides: options.themeOverrides, detectedTheme: options.detectedTheme, onThemeModeChange: options.onThemeModeChange, flushMemory: options.flushMemory, runMemoryCompaction: options.runMemoryCompaction, runMemorySummary: options.runMemorySummary, runMemoryRefresh: options.runMemoryRefresh, bypassEnabled: options.bypassEnabled, updateNotice: options.updateNotice, updateNoticeRefresh: options.updateNoticeRefresh, hookController: options.hookController, externalRuntime: options.externalRuntime, onExit: onExit }));
6
+ return (_jsx(App, { agent: agent, args: args, sessionManager: options.sessionManager, switchSession: options.switchSession, createProvider: options.createProvider, registry: options.registry, skillRegistry: options.skillRegistry, planHandlerRef: options.planHandlerRef, approvalHandlerRef: options.approvalHandlerRef, questionController: options.questionController, bashAllowlist: options.bashAllowlist, settingsManager: options.settingsManager, lspService: options.lspService, mcpManager: options.mcpManager, goalStore: options.goalStore, processManager: options.processManager, tasksAutoResume: options.tasksAutoResume, promotionChannel: options.promotionChannel, themeMode: options.themeMode, themeOverrides: options.themeOverrides, detectedTheme: options.detectedTheme, onThemeModeChange: options.onThemeModeChange, flushMemory: options.flushMemory, runMemoryCompaction: options.runMemoryCompaction, runMemorySummary: options.runMemorySummary, runMemoryRefresh: options.runMemoryRefresh, bypassEnabled: options.bypassEnabled, updateNotice: options.updateNotice, updateNoticeRefresh: options.updateNoticeRefresh, hookController: options.hookController, externalRuntime: options.externalRuntime, onExit: onExit }));
7
7
  }
8
8
  /**
9
9
  * Best-effort terminal restore for abnormal exits. Bubble renders into the
@@ -9,9 +9,13 @@
9
9
  * members as their events stream in.
10
10
  */
11
11
  import { type SubagentGroup } from "./subagent-view.js";
12
+ import type { BackgroundTaskInfo } from "../tasks/manager.js";
12
13
  export type { SubagentGroup };
13
14
  export interface SubagentInspectorProps {
14
15
  groups: SubagentGroup[];
15
16
  onCancel: () => void;
17
+ /** Background tasks of the bound session (design §2.5). */
18
+ tasks?: BackgroundTaskInfo[];
19
+ onKillTask?: (taskId: string) => void;
16
20
  }
17
- export declare function SubagentInspector({ groups, onCancel }: SubagentInspectorProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare function SubagentInspector({ groups, onCancel, tasks, onKillTask }: SubagentInspectorProps): import("react/jsx-runtime").JSX.Element;
@@ -16,7 +16,7 @@ import { useTheme } from "./theme.js";
16
16
  import { padVisual, truncateVisual } from "../text-display.js";
17
17
  import { latestSubagentNote, subagentDescriptor, subagentLabel, subagentStatusColor, subagentSummary, } from "./subagent-view.js";
18
18
  const STATUS_FILTERS = [null, "running", "queued", "completed", "failed"];
19
- export function SubagentInspector({ groups, onCancel }) {
19
+ export function SubagentInspector({ groups, onCancel, tasks = [], onKillTask }) {
20
20
  const theme = useTheme();
21
21
  const { stdout } = useStdout();
22
22
  const termHeight = stdout?.rows || 24;
@@ -28,9 +28,13 @@ export function SubagentInspector({ groups, onCancel }) {
28
28
  const [filterIdx, setFilterIdx] = useState(0);
29
29
  const statusFilter = STATUS_FILTERS[filterIdx];
30
30
  const allMembers = useMemo(() => groups.flatMap((g) => g.members), [groups]);
31
- // Flat row list: a header per multi-member group, then its (filtered) members.
31
+ // Flat row list: background tasks first (design §2.5), then a header per
32
+ // multi-member group and its (filtered) members.
32
33
  const rows = useMemo(() => {
33
34
  const out = [];
35
+ for (const task of tasks) {
36
+ out.push({ type: "task", task, key: task.id });
37
+ }
34
38
  for (const group of groups) {
35
39
  const members = statusFilter
36
40
  ? group.members.filter((m) => (m.status ?? "running") === statusFilter)
@@ -44,12 +48,13 @@ export function SubagentInspector({ groups, onCancel }) {
44
48
  });
45
49
  }
46
50
  return out;
47
- }, [groups, statusFilter]);
48
- const memberRowIndices = useMemo(() => rows.map((row, i) => (row.type === "member" ? i : -1)).filter((i) => i >= 0), [rows]);
51
+ }, [groups, statusFilter, tasks]);
52
+ const memberRowIndices = useMemo(() => rows.map((row, i) => (row.type === "member" || row.type === "task" ? i : -1)).filter((i) => i >= 0), [rows]);
49
53
  const clampedIdx = memberRowIndices.length === 0 ? 0 : Math.min(selectedIdx, memberRowIndices.length - 1);
50
54
  const selectedRowIndex = memberRowIndices[clampedIdx] ?? -1;
51
55
  const selectedRow = rows[selectedRowIndex];
52
56
  const selectedMember = selectedRow?.type === "member" ? selectedRow.member : undefined;
57
+ const selectedTask = selectedRow?.type === "task" ? selectedRow.task : undefined;
53
58
  useInput((input, key) => {
54
59
  if (isKeyReleaseEvent(key))
55
60
  return;
@@ -91,9 +96,13 @@ export function SubagentInspector({ groups, onCancel }) {
91
96
  setView("detail");
92
97
  return;
93
98
  }
99
+ if (input === "x" && selectedTask && selectedTask.status === "running") {
100
+ onKillTask?.(selectedTask.id);
101
+ return;
102
+ }
94
103
  });
95
- if (groups.length === 0) {
96
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Subagents" }), _jsx(Text, { color: theme.muted, children: "No subagents have been spawned yet. Esc to close." })] }));
104
+ if (groups.length === 0 && tasks.length === 0) {
105
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Subagents & tasks" }), _jsx(Text, { color: theme.muted, children: "No subagents or background tasks yet. Esc to close." })] }));
97
106
  }
98
107
  if (view === "detail" && selectedMember) {
99
108
  return (_jsx(SubagentDetail, { member: selectedMember, group: selectedRow?.type === "member" ? selectedRow.group : undefined, scroll: detailScroll, maxVisible: maxVisible, termWidth: termWidth }));
@@ -103,8 +112,15 @@ export function SubagentInspector({ groups, onCancel }) {
103
112
  const visible = rows.slice(start, start + maxVisible);
104
113
  const labelWidth = 12;
105
114
  const descriptorWidth = Math.max(20, Math.min(46, termWidth - 48));
106
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Subagents \u00B7 working traces" }), _jsxs(Text, { color: theme.muted, children: [allMembers.length, " member", allMembers.length === 1 ? "" : "s", " \u00B7 ", subagentSummary(allMembers), statusFilter ? ` · filter: ${statusFilter}` : ""] }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 select \u00B7 Enter/\u2192 open trace \u00B7 f filter status \u00B7 Esc close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [rows.length === 0 && (_jsx(Text, { color: theme.muted, children: "No members match the current filter." })), visible.map((row, i) => {
115
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: tasks.length > 0 ? "Subagents & tasks · working traces" : "Subagents · working traces" }), _jsxs(Text, { color: theme.muted, children: [allMembers.length, " member", allMembers.length === 1 ? "" : "s", " \u00B7 ", subagentSummary(allMembers), tasks.length > 0 ? ` · ${tasks.length} task${tasks.length === 1 ? "" : "s"}` : "", statusFilter ? ` · filter: ${statusFilter}` : ""] }), _jsxs(Text, { color: theme.muted, children: ["\u2191/\u2193 select \u00B7 Enter/\u2192 open trace", tasks.length > 0 ? " · x kill task" : "", " \u00B7 f filter status \u00B7 Esc close"] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [rows.length === 0 && (_jsx(Text, { color: theme.muted, children: "No members match the current filter." })), visible.map((row, i) => {
107
116
  const actualIndex = start + i;
117
+ if (row.type === "task") {
118
+ const task = row.task;
119
+ const isSelected = actualIndex === selectedRowIndex;
120
+ const elapsed = Math.max(0, Math.round(((task.endedAt ?? Date.now()) - task.startedAt) / 1000));
121
+ const statusColor = task.status === "running" ? theme.warning : task.status === "completed" ? theme.success : theme.error;
122
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.accent : undefined, children: isSelected ? "> " : " " }), _jsx(Text, { color: statusColor, children: padVisual(task.id, labelWidth) }), _jsxs(Text, { color: theme.traceAction, children: [" ", padVisual(truncateVisual(task.description || task.command, descriptorWidth), descriptorWidth)] }), _jsxs(Text, { color: statusColor, children: [" ", padVisual(task.status, 9)] }), _jsxs(Text, { color: theme.traceDetail, children: [" ", elapsed, "s", task.exitCode != null ? ` · exit ${task.exitCode}` : ""] })] }, row.key));
123
+ }
108
124
  if (row.type === "header") {
109
125
  return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsxs(Text, { bold: true, color: theme.muted, children: ["\u25A6 ", row.group.kind, " \u00B7 ", truncateVisual(row.group.label, termWidth - 18), " (", row.group.members.length, ")"] }) }, `h-${actualIndex}`));
110
126
  }
@@ -32,6 +32,10 @@ function GradientText({ text, from, to }) {
32
32
  export function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, sessionLabel, providerId, modelId, modelLabel, thinkingLabel, }) {
33
33
  const theme = useTheme();
34
34
  const effectiveWidth = Math.max(24, Math.min(terminalColumns - 2, 96));
35
+ // The model row here refreshes live only while the banner is still in the
36
+ // dynamic region (empty transcript). Once the first settled row commits it
37
+ // into <Static> scrollback it freezes; from then on the accent-colored
38
+ // "Model switched" transcript notices carry model changes.
35
39
  const modelLine = formatModelLine({
36
40
  providerId,
37
41
  modelId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {