@bubblebrain-ai/bubble 0.0.46 → 0.0.48

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 (95) hide show
  1. package/dist/agent/child-runner.js +11 -0
  2. package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
  3. package/dist/agent/internal-reminder-sanitizer.js +13 -0
  4. package/dist/agent/subagent/router.d.ts +65 -0
  5. package/dist/agent/subagent/router.js +198 -0
  6. package/dist/agent/subagent/runtime.d.ts +203 -0
  7. package/dist/agent/subagent/runtime.js +789 -0
  8. package/dist/agent/subagent-control.d.ts +5 -0
  9. package/dist/agent/subagent-store.d.ts +18 -2
  10. package/dist/agent/subagent-store.js +32 -1
  11. package/dist/agent/tool-intent.d.ts +10 -0
  12. package/dist/agent/tool-intent.js +18 -0
  13. package/dist/agent/tool-visibility.d.ts +10 -0
  14. package/dist/agent/tool-visibility.js +6 -0
  15. package/dist/agent/workflow/runs.d.ts +72 -0
  16. package/dist/agent/workflow/runs.js +157 -0
  17. package/dist/agent.d.ts +78 -96
  18. package/dist/agent.js +266 -1072
  19. package/dist/context/compact-llm.js +14 -5
  20. package/dist/context/compact.d.ts +9 -0
  21. package/dist/context/compact.js +36 -10
  22. package/dist/context/compaction-files.d.ts +44 -0
  23. package/dist/context/compaction-files.js +148 -0
  24. package/dist/context/llm-compactor.js +24 -5
  25. package/dist/feishu/agent-host/run-driver.js +3 -0
  26. package/dist/feishu/serve.js +13 -9
  27. package/dist/goal/tools.js +12 -3
  28. package/dist/main.js +24 -19
  29. package/dist/model-catalog.js +36 -2
  30. package/dist/model-config.d.ts +2 -0
  31. package/dist/model-data.generated.d.ts +16 -0
  32. package/dist/model-data.generated.js +275 -0
  33. package/dist/model-pricing.d.ts +11 -7
  34. package/dist/model-pricing.js +0 -0
  35. package/dist/oauth/grok.d.ts +1 -1
  36. package/dist/oauth/grok.js +5 -2
  37. package/dist/oauth/storage.d.ts +9 -2
  38. package/dist/oauth/storage.js +16 -9
  39. package/dist/orchestrator/default-hooks.d.ts +20 -0
  40. package/dist/orchestrator/default-hooks.js +47 -295
  41. package/dist/orchestrator/hooks.d.ts +6 -39
  42. package/dist/permission/mode.js +1 -1
  43. package/dist/prompt/reminders.d.ts +9 -35
  44. package/dist/prompt/reminders.js +30 -136
  45. package/dist/prompt/task-reminders.d.ts +0 -31
  46. package/dist/prompt/task-reminders.js +0 -87
  47. package/dist/provider-ai-sdk.d.ts +2 -0
  48. package/dist/provider-ai-sdk.js +1 -0
  49. package/dist/provider-anthropic.d.ts +2 -0
  50. package/dist/provider-anthropic.js +3 -1
  51. package/dist/provider-ark-responses.d.ts +2 -0
  52. package/dist/provider-ark-responses.js +1 -0
  53. package/dist/provider-grok.d.ts +26 -0
  54. package/dist/provider-grok.js +39 -0
  55. package/dist/provider-registry.d.ts +14 -1
  56. package/dist/provider-registry.js +131 -3
  57. package/dist/provider-transform.js +37 -1
  58. package/dist/provider.d.ts +7 -0
  59. package/dist/provider.js +13 -1
  60. package/dist/sdk/index.d.ts +152 -0
  61. package/dist/sdk/index.js +400 -0
  62. package/dist/session.js +9 -1
  63. package/dist/slash-commands/commands.js +33 -19
  64. package/dist/tools/exit-plan-mode.js +8 -0
  65. package/dist/tui/detect-theme.d.ts +1 -0
  66. package/dist/tui/detect-theme.js +11 -5
  67. package/dist/tui-ink/app.d.ts +2 -6
  68. package/dist/tui-ink/app.js +11 -18
  69. package/dist/tui-ink/display-history.d.ts +0 -2
  70. package/dist/tui-ink/display-history.js +0 -52
  71. package/dist/tui-ink/footer.js +2 -2
  72. package/dist/tui-ink/input-box.js +18 -6
  73. package/dist/tui-ink/message-list.js +62 -23
  74. package/dist/tui-ink/run.js +10 -5
  75. package/dist/tui-ink/sync-output.d.ts +17 -0
  76. package/dist/tui-ink/sync-output.js +35 -0
  77. package/dist/tui-ink/theme.d.ts +0 -1
  78. package/dist/tui-ink/theme.js +0 -2
  79. package/dist/tui-ink/trace-groups.js +1 -1
  80. package/dist/types.d.ts +21 -1
  81. package/package.json +16 -2
  82. package/dist/agent/discovery-barrier.d.ts +0 -21
  83. package/dist/agent/discovery-barrier.js +0 -173
  84. package/dist/agent/evidence-tracker.d.ts +0 -15
  85. package/dist/agent/evidence-tracker.js +0 -93
  86. package/dist/agent/execution-governor.d.ts +0 -36
  87. package/dist/agent/execution-governor.js +0 -218
  88. package/dist/agent/task-classifier.d.ts +0 -3
  89. package/dist/agent/task-classifier.js +0 -114
  90. package/dist/agent/task-size.d.ts +0 -9
  91. package/dist/agent/task-size.js +0 -33
  92. package/dist/agent/tool-arbiter.d.ts +0 -7
  93. package/dist/agent/tool-arbiter.js +0 -33
  94. package/dist/orchestrator/workflow.d.ts +0 -10
  95. package/dist/orchestrator/workflow.js +0 -22
@@ -2,14 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useApp, useInput } from "ink";
4
4
  import { AgentAbortError, INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
5
- import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
5
+ import { isHiddenToolMetadata } from "../agent/tool-visibility.js";
6
6
  import { SessionManager } from "../session.js";
7
7
  import { registry as slashRegistry } from "../slash-commands/index.js";
8
8
  import { UserConfig, maskKey } from "../config.js";
9
9
  import { InputBox, isCtrlCInput, isCtrlLetterInput, } from "./input-box.js";
10
10
  import { MessageList } from "./message-list.js";
11
11
  import { isMultiplexedTerminal } from "./terminal-env.js";
12
- import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
12
+ import { appendTextPart, appendToolPart, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
13
13
  import { AgentRunInputQueue } from "../agent/input-controller.js";
14
14
  import { canvasBackgroundFor, paletteFor, ThemeProvider, useTheme } from "./theme.js";
15
15
  import { isPrintablePickerInput, ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
@@ -45,7 +45,7 @@ import { continuationPrompt, initialPrompt } from "../goal/prompts.js";
45
45
  import { shouldContinueGoal, stopReasonNotice } from "../goal/engine.js";
46
46
  import { goalCompleteNotice, goalIndicatorLine, goalSummaryText } from "../goal/format.js";
47
47
  import { tokenUsageTotal } from "../goal/usage.js";
48
- import { formatInternalContextBlock } from "../agent/internal-reminder-sanitizer.js";
48
+ import { formatInternalContextBlock, isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
49
49
  import { MAX_ACTIVE_LOOPS, decideLoopFiring, formatInterval, formatLoopList, parseLoopCommand, } from "../loop/engine.js";
50
50
  import { TaskWakeCoalescer, findDanglingTaskStarts, formatTaskWakeSummary, isPidAlive, shouldFireTaskWake, taskEligibleForWake, } from "../tasks/wake.js";
51
51
  const TASK_WAKE_DEBOUNCE_MS = 2000;
@@ -87,17 +87,9 @@ function buildTips(agent, registry, grokActive = false) {
87
87
  function slashResultNoticeKind(result) {
88
88
  return /^(Grok m|M)odel switched to /.test(result) ? "ui_notice" : undefined;
89
89
  }
90
- /**
91
- * True when a user message is nothing but harness-injected internal blocks
92
- * (goal kicks, task wakes) — hidden live, so hidden on reconstruction too.
93
- */
94
- export function isInternalBlockOnlyContent(content) {
95
- if (typeof content !== "string")
96
- return false;
97
- const trimmed = content.trim();
98
- return /^<bubble_internal_(?:context|reminder)\b/.test(trimmed)
99
- && /<\/bubble_internal_(?:context|reminder)>$/.test(trimmed);
100
- }
90
+ // Canonical implementation lives in the sanitizer module so non-TUI surfaces
91
+ // (session pickers, desktop host) share it; re-exported here for existing users.
92
+ export { isInternalBlockOnlyContent };
101
93
  /** Status-row summary for running background tasks (design §2.5). */
102
94
  function taskRowSummary(tasks, nowTick) {
103
95
  const first = tasks[0];
@@ -298,7 +290,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
298
290
  const themePickerRevertRef = useRef("auto");
299
291
  const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
300
292
  const { exit } = useApp();
301
- const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
293
+ const [messages, setMessages] = useState(() => reconstructDisplayMessages(agent.messages));
302
294
  const nextImageDisplayLabelStartRef = useRef(nextImageDisplayLabelStart(messages));
303
295
  const [isRunning, setIsRunning] = useState(false);
304
296
  const [streamingContent, setStreamingContent] = useState("");
@@ -733,9 +725,10 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
733
725
  // <system-reminder>, so we do not rebuild the cache-friendly system prompt here.
734
726
  if (key.tab && key.shift && !pickerMode) {
735
727
  const nextMode = getNextPermissionMode(agent.mode);
728
+ // setMode fires onModeUpdate, which is the single writer of the
729
+ // mode_switch marker — appending here too double-logs every switch.
736
730
  agent.setMode(nextMode);
737
731
  setPermissionMode(nextMode);
738
- sessionManager?.appendMarker("mode_switch", nextMode);
739
732
  return;
740
733
  }
741
734
  if (key.escape && !pickerMode) {
@@ -746,7 +739,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
746
739
  }
747
740
  });
748
741
  const updateDisplayMessages = useCallback((updater) => {
749
- setMessages((prev) => compactDisplayMessages(updater(prev).map(withMessageKey)));
742
+ setMessages((prev) => updater(prev).map(withMessageKey));
750
743
  }, []);
751
744
  // Non-append transcript rebuilds (/clear, /compact, /rewind, session switch)
752
745
  // replace the settled list rather than extending it. The rows already
@@ -1662,8 +1655,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1662
1655
  break;
1663
1656
  }
1664
1657
  case "mode_changed": {
1658
+ // Marker already persisted by onModeUpdate at setMode time.
1665
1659
  setPermissionMode(event.mode);
1666
- sessionManager?.appendMarker("mode_switch", event.mode);
1667
1660
  break;
1668
1661
  }
1669
1662
  case "input_applied": {
@@ -49,7 +49,6 @@ export interface DisplayToolCall {
49
49
  */
50
50
  rawArguments?: string;
51
51
  result?: string;
52
- resultCollapsed?: boolean;
53
52
  isError?: boolean;
54
53
  metadata?: ToolResultMetadata;
55
54
  /** Set when the tool_start event was received. Used to render elapsed time. */
@@ -61,7 +60,6 @@ export declare function appendToolPart(parts: DisplayMessagePart[], toolCall: Di
61
60
  export declare function snapshotDisplayParts(parts: DisplayMessagePart[]): DisplayMessagePart[];
62
61
  export declare function contentFromParts(parts: DisplayMessagePart[]): string;
63
62
  export declare function toolCallsFromParts(parts: DisplayMessagePart[]): DisplayToolCall[];
64
- export declare function compactDisplayMessages(messages: DisplayMessage[]): DisplayMessage[];
65
63
  /**
66
64
  * Find the most recent compaction summary embedded in the agent's system
67
65
  * messages. Bubble's compaction step rewrites the system transcript so that
@@ -87,64 +87,12 @@ export function contentFromParts(parts) {
87
87
  export function toolCallsFromParts(parts) {
88
88
  return parts.flatMap((part) => part.type === "tools" ? part.toolCalls : []);
89
89
  }
90
- const FULL_DETAIL_WINDOW = 24;
91
- // Folding policy: message text (content, reasoning) is NEVER rewritten or
92
- // truncated — what the user or the assistant said renders verbatim. All
93
- // messages stay in the list (the alt-screen viewport scrolls them); older
94
- // messages only collapse bulky tool-result bodies, which the UI re-expands
95
- // on demand.
96
- export function compactDisplayMessages(messages) {
97
- if (messages.length === 0) {
98
- return messages;
99
- }
100
- const visible = messages.filter((message) => message.syntheticKind !== "ui_summary");
101
- const detailStart = Math.max(0, visible.length - FULL_DETAIL_WINDOW);
102
- return visible.map((message, index) => (index < detailStart ? compactDisplayMessage(message) : message));
103
- }
104
- // Messages that already went through compaction. Re-compacting them would
105
- // produce equal-but-new objects on every transcript update, which defeats the
106
- // React.memo row cache in message-list.tsx — settled rows must keep identity.
107
- const compactedMessages = new WeakSet();
108
- function compactDisplayMessage(message) {
109
- if (message.syntheticKind) {
110
- return message;
111
- }
112
- if (compactedMessages.has(message)) {
113
- return message;
114
- }
115
- const compacted = {
116
- ...message,
117
- toolCalls: message.toolCalls?.map(compactToolCall),
118
- parts: message.parts?.map(compactDisplayPart),
119
- };
120
- compactedMessages.add(compacted);
121
- return compacted;
122
- }
123
90
  function cloneToolCall(toolCall) {
124
91
  return {
125
92
  ...toolCall,
126
93
  args: { ...toolCall.args },
127
94
  };
128
95
  }
129
- function compactDisplayPart(part) {
130
- if (part.type === "text") {
131
- return part;
132
- }
133
- return {
134
- type: "tools",
135
- toolCalls: part.toolCalls.map(compactToolCall),
136
- };
137
- }
138
- function compactToolCall(toolCall) {
139
- if (toolCall.result === undefined) {
140
- return toolCall;
141
- }
142
- return {
143
- ...toolCall,
144
- result: undefined,
145
- resultCollapsed: true,
146
- };
147
- }
148
96
  const PREVIOUS_SUMMARY_PREFIX = /^Previous conversation summary:\s*\n?([\s\S]*)$/;
149
97
  const TURN_SUMMARY_PREFIX = /^Earlier in this turn \(compacted to free context\):\s*\n?([\s\S]*)$/;
150
98
  /**
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { useTheme } from "./theme.js";
4
4
  import { PERMISSION_MODE_INFO } from "../permission/mode.js";
@@ -30,7 +30,7 @@ function ModeBadge({ mode }) {
30
30
  const info = PERMISSION_MODE_INFO[mode];
31
31
  const color = theme[info.color] ?? theme.muted;
32
32
  const symbol = info.symbol ? `${info.symbol} ` : "";
33
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: color, bold: true, children: [symbol, info.shortTitle, " on"] }), _jsx(Text, { color: theme.muted, children: " \u21E7\u21E5" })] }));
33
+ return (_jsxs(Text, { color: color, bold: true, children: [symbol, info.shortTitle, " on"] }));
34
34
  }
35
35
  export function buildFooterData(input) {
36
36
  return input;
@@ -8,7 +8,7 @@ import { useTheme } from "./theme.js";
8
8
  import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
9
9
  import { bareImageFilenameFromPaste, extractImagePathTokens, ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
10
10
  import { appendHistoryEntry, loadHistoryEntriesSync, pushHistoryEntry, stepHistory, } from "./input-history.js";
11
- import { cloneComposerBuffer, createComposerBuffer, deleteComposerBackward, deleteComposerForward, expandComposerBuffer, hasActiveComposerPastes, insertComposerPaste, insertComposerText, moveComposerCursor, replaceComposerRange, } from "./composer-buffer.js";
11
+ import { cloneComposerBuffer, createComposerBuffer, deleteComposerBackward, deleteComposerForward, expandComposerBuffer, insertComposerPaste, insertComposerText, moveComposerCursor, replaceComposerRange, } from "./composer-buffer.js";
12
12
  import { isKeyReleaseEvent } from "./key-events.js";
13
13
  import { PasteOperationTracker } from "./paste-operation-tracker.js";
14
14
  import { stripTerminalMouseSequences } from "./terminal-mouse.js";
@@ -26,6 +26,18 @@ export function createComposerDraftSnapshot(input) {
26
26
  imageLabelStartOverride: input.imageLabelStartOverride,
27
27
  };
28
28
  }
29
+ /**
30
+ * Rebuild a composer buffer from a persisted history entry. Text long enough to
31
+ * have been collapsed at paste time is collapsed again into a `[Pasted text #N …]`
32
+ * marker, but its full content is preserved in the buffer's paste spans so the
33
+ * composer stays compact, no content is lost, and the next submit re-expands it.
34
+ */
35
+ function bufferFromHistoryText(text) {
36
+ if (shouldCollapsePastedContent(text)) {
37
+ return insertComposerPaste(createComposerBuffer(), text);
38
+ }
39
+ return createComposerBuffer(text);
40
+ }
29
41
  /**
30
42
  * Browse persisted history without turning marker-shaped text into live paste
31
43
  * tokens, while preserving the exact unsent draft in memory for the final
@@ -68,7 +80,7 @@ export function stepComposerHistory(state, history, direction) {
68
80
  };
69
81
  }
70
82
  return {
71
- buffer: createComposerBuffer(result.text),
83
+ buffer: bufferFromHistoryText(result.text),
72
84
  attachments: (result.images ?? []).map((attachment) => ({ ...attachment })),
73
85
  imageLabelStartOverride: result.imageDisplayStart ?? null,
74
86
  historyIndex: result.index,
@@ -716,10 +728,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
716
728
  return;
717
729
  submittedPayloadFingerprintRef.current = fingerprint;
718
730
  deliver(payload);
719
- // A collapsed text-paste marker cannot be safely replayed once its
720
- // in-memory reference is gone; skip those. Image-label stripping is fine to
721
- // replay (the attachments are stored on the history entry).
722
- if (!hasActiveComposerPastes(submittedBuffer) && (expandedText.trim().length > 0 || attachments.length > 0)) {
731
+ // Always record the submitted text so ↑/↓ can recall it. The full expanded
732
+ // text is persisted (content is never lost); on recall, long text is
733
+ // re-collapsed into a paste marker that still carries its full content.
734
+ if (expandedText.trim().length > 0 || attachments.length > 0) {
723
735
  const historyEntry = {
724
736
  text: expandedText,
725
737
  images: attachments,
@@ -10,6 +10,7 @@ import { buildTraceGroups, executeCommandBlock, formatTracePath, shouldInlineExe
10
10
  import { EDIT_COLLAPSED_DIFF_LINES, formatEditSuccessSummary, getEditDiffDetails } from "./edit-diff.js";
11
11
  import { latestSubagentNote, sortSubagents, subagentDescriptor, subagentLabel, subagentStatusColor, subagentSummary, } from "./subagent-view.js";
12
12
  import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
13
+ import { stripFileBlocks } from "../context/compaction-files.js";
13
14
  import { splitImageDisplayContent } from "../tui/image-display.js";
14
15
  const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
15
16
  /**
@@ -152,7 +153,9 @@ const MessageItem = React.memo(function MessageItem({ message, terminalColumns,
152
153
  : sanitizeInternalReminderBlocks(part.content).trim() !== "");
153
154
  const toolCallCount = message.toolCalls?.length ?? 0;
154
155
  const hasVisibleAssistantContent = (hasParts ? hasVisibleParts : (!!visibleContent.trim() || toolCallCount > 0)) ||
155
- (!!visibleReasoning && (showThinking || verboseTrace)) ||
156
+ // Reasoning now always renders (collapsed by default), so its presence
157
+ // alone makes a turn visible.
158
+ !!visibleReasoning ||
156
159
  // A finalized turn carries taskElapsedMs and renders a TaskDurationLine even
157
160
  // when its text/parts are empty — mirror that so the duration isn't dropped.
158
161
  // (The verbose TurnDigest needs no term: whenever it renders, the same
@@ -160,7 +163,7 @@ const MessageItem = React.memo(function MessageItem({ message, terminalColumns,
160
163
  message.taskElapsedMs !== undefined;
161
164
  if (!hasVisibleAssistantContent)
162
165
  return null;
163
- return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (showThinking || 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 })), visibleContent.trim() && (
166
+ return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (_jsx(ReasoningTraceBlock, { reasoning: visibleReasoning, expanded: showThinking || verboseTrace })), 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 })), visibleContent.trim() && (
164
167
  // Thread the real terminal width down (minus the row's paddingX
165
168
  // inset); without it, width-aware blocks like tables fall back to
166
169
  // a stale/default useTerminalSize and mis-budget their columns.
@@ -174,7 +177,7 @@ function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, s
174
177
  const visibleParts = deferredParts.length > 0
175
178
  ? deferredParts
176
179
  : fallbackStreamingParts(deferredContent, tools);
177
- return (_jsxs(Box, { flexDirection: "column", children: [visibleReasoning && (showThinking || verboseTrace) && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }) })), visibleParts.length > 0 && (
180
+ return (_jsxs(Box, { flexDirection: "column", children: [visibleReasoning && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning, expanded: showThinking || verboseTrace, streaming: true }) })), visibleParts.length > 0 && (
178
181
  // marginTop=1 matches the committed MessageItem layout exactly, so the
179
182
  // gap under the user message is identical while streaming and after the
180
183
  // turn commits — no spacing jump at finalize time. (The old marginTop=0
@@ -279,22 +282,33 @@ function TraceGroupBlock({ group, terminalColumns, pendingApproval, compactTop,
279
282
  const commandWidth = Math.max(14, terminalColumns - group.title.length - 20);
280
283
  const detailWidth = Math.max(20, terminalColumns - 8);
281
284
  const detailLines = group.previewLines.length > 0 ? group.previewLines : group.items;
285
+ // Finished, error-free Execute rows collapse to a one-line summary: the
286
+ // multi-line command body and stdout preview only earn space while running
287
+ // (you want to see what's executing) or on failure (you need the error).
288
+ // Ctrl+O (verbose trace) shows everything.
289
+ const collapsedExecute = group.kind === "execute" && !group.pending && !waiting && !group.hasError;
282
290
  // A model-provided bash description owns the header slot; the command then
283
291
  // always renders as a block below. Without one, single-line commands that
284
292
  // fit stay inline; anything longer becomes a wrapped block preserving the
285
293
  // command's own line structure — commands are never clipped mid-line.
294
+ // Collapsed rows always inline the (truncated) command instead of a block.
286
295
  const showDescription = group.kind === "execute" && !!group.description;
287
296
  const inlineCommand = !showDescription && group.command
288
297
  ? (group.kind === "execute"
289
- ? (shouldInlineExecuteCommand(group, commandWidth) ? group.command : undefined)
298
+ ? (collapsedExecute || shouldInlineExecuteCommand(group, commandWidth)
299
+ ? truncateVisual(group.command.replace(/\s+/g, " "), commandWidth)
300
+ : undefined)
290
301
  : (visualWidth(group.command) <= commandWidth ? group.command : undefined))
291
302
  : undefined;
292
- const commandBlock = group.command && !inlineCommand
303
+ const commandBlock = group.command && !inlineCommand && !collapsedExecute
293
304
  ? (group.kind === "execute"
294
305
  ? executeCommandBlock(group, EXECUTE_COMMAND_BLOCK_MAX_LINES)
295
306
  : { lines: [group.command], omitted: 0 })
296
307
  : null;
297
- 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"] }) }))] }));
308
+ const collapsedOutputLines = group.previewLines.length + group.omitted;
309
+ 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"] }))] })), collapsedExecute ? (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["\u23BF ", collapsedOutputLines > 0
310
+ ? `${collapsedOutputLines} line${collapsedOutputLines === 1 ? "" : "s"} output · Ctrl+O to view`
311
+ : "no output"] }) })) : 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 && !collapsedExecute && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["... ", group.omitted, " more, Ctrl+O to view"] }) }))] }));
298
312
  }
299
313
  function EditTraceBlock({ tool, details, terminalColumns, compactTop, status, }) {
300
314
  const theme = useTheme();
@@ -336,12 +350,33 @@ function approvalMatchesTool(hint, tc) {
336
350
  return !hint.path || hint.path === tc.args.path;
337
351
  }
338
352
  function isToolPending(tool) {
339
- return tool.result === undefined && tool.resultCollapsed !== true;
353
+ return tool.result === undefined;
340
354
  }
341
- function ReasoningTraceBlock({ reasoning }) {
355
+ /** Live rolling window size — enough to read the model's train of thought. */
356
+ const REASONING_STREAM_WINDOW = 5;
357
+ /** Lines kept when a finished turn's reasoning is collapsed. */
358
+ const REASONING_COLLAPSED_LINES = 2;
359
+ /**
360
+ * Reasoning trace with three states, because a reasoning model can spend
361
+ * minutes here (qwen3.8-max at high effort drafts entire files in thought):
362
+ * hiding it looks like a hang, printing all of it buries the transcript.
363
+ *
364
+ * streaming + compact rolling last N lines, so there is always motion
365
+ * settled + compact opening lines + "(ctrl+o to expand)"
366
+ * expanded everything (Ctrl+T thinking, or Ctrl+O verbose)
367
+ */
368
+ function ReasoningTraceBlock({ reasoning, expanded = false, streaming = false, }) {
342
369
  const theme = useTheme();
343
370
  const lines = React.useMemo(() => reasoning.split("\n").filter((l) => l.trim() !== ""), [reasoning]);
344
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: theme.thinkingDim, dimColor: true, children: ["\u273B Reasoning trace", lines.length > 0 ? ` · ${lines.length} line${lines.length === 1 ? "" : "s"}` : ""] }), lines.map((line, i) => (_jsx(Text, { color: theme.thinkingDim, dimColor: true, italic: true, children: line }, i)))] }));
371
+ if (lines.length === 0)
372
+ return null;
373
+ const visible = expanded
374
+ ? lines
375
+ : streaming
376
+ ? lines.slice(-REASONING_STREAM_WINDOW)
377
+ : lines.slice(0, REASONING_COLLAPSED_LINES);
378
+ const hidden = lines.length - visible.length;
379
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: theme.thinkingDim, dimColor: true, children: ["\u273B ", streaming && !expanded ? "Thinking…" : "Thinking"] }), visible.map((line, i) => (_jsx(Text, { color: theme.thinkingDim, dimColor: true, italic: true, children: line }, i))), hidden > 0 && (_jsx(Text, { color: theme.thinkingDim, dimColor: true, children: streaming ? "…" : "… (ctrl+o to expand)" }))] }));
345
380
  }
346
381
  function CompactionSummaryBlock({ message }) {
347
382
  const theme = useTheme();
@@ -351,22 +386,29 @@ function CompactionSummaryBlock({ message }) {
351
386
  // markup before rendering, so a summary that echoed it never reaches the
352
387
  // transcript. Belt-and-suspenders — the summarizer is fed sanitized history,
353
388
  // but the summary is model-generated and also re-injected as context.
354
- const summary = sanitizeInternalReminderBlocks(message.compactionSummary ?? "").trim() || undefined;
389
+ // File-tracking blocks (<read-files>/<modified-files>) are model-facing
390
+ // bookkeeping welded onto the summary — never render them raw either.
391
+ const summary = stripFileBlocks(sanitizeInternalReminderBlocks(message.compactionSummary ?? "")).trim() || undefined;
355
392
  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 }) })] }))] }));
356
393
  }
357
394
  function UserMessageBlock({ content, terminalColumns, inputStatus, separateFromPrevious = false, }) {
358
395
  const theme = useTheme();
359
396
  const badge = userInputStatusBadgeLabel(inputStatus);
360
- // Rail and its right gutter must share the bubble background; otherwise the
361
- // terminal background shows up as a dark seam between rail and message.
362
- const railWidth = 2;
397
+ // Defense in depth: pure internal-block rows are dropped upstream
398
+ // (isInternalBlockOnlyContent), but a block with trailing text — or a
399
+ // truncated block — would slip that anchored filter. Never render markup.
400
+ const visibleContent = sanitizeInternalReminderBlocks(content);
401
+ // Preserve the message's existing text inset after removing the decorative
402
+ // rail: the former two rail columns become ordinary background padding.
403
+ const leftPaddingWidth = 3;
404
+ const rightPaddingWidth = 1;
363
405
  const horizontalRoom = Math.max(20, terminalColumns - 2);
364
- const bubbleTextWidth = Math.max(1, horizontalRoom - railWidth - 2);
365
- const { bodyLines, referenceLines } = splitImageDisplayContent(content);
406
+ const bubbleTextWidth = Math.max(1, horizontalRoom - leftPaddingWidth - rightPaddingWidth);
407
+ const { bodyLines, referenceLines } = splitImageDisplayContent(visibleContent);
366
408
  const wrappedLines = bodyLines
367
409
  .flatMap((line) => wrapByVisualWidth(line, bubbleTextWidth));
368
- const attachmentReferenceIndent = " ".repeat(railWidth + 1);
369
- return (_jsxs(Box, { flexDirection: "column", marginTop: separateFromPrevious ? 1 : 0, 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: "▌ " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))), referenceLines.map((line, index) => (_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: `${attachmentReferenceIndent}${line}` }) }, `attachment-${index}`)))] }));
410
+ const attachmentReferenceIndent = " ".repeat(leftPaddingWidth);
411
+ return (_jsxs(Box, { flexDirection: "column", marginTop: separateFromPrevious ? 1 : 0, 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) => (_jsx(Box, { children: _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: `${" ".repeat(leftPaddingWidth)}${padVisual(line || " ", bubbleTextWidth)}${" ".repeat(rightPaddingWidth)}` }) }, index))), referenceLines.map((line, index) => (_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: `${attachmentReferenceIndent}${line}` }) }, `attachment-${index}`)))] }));
370
412
  }
371
413
  function PendingInputMessagesBlock({ messages, terminalColumns, title, hint, bulletColor, }) {
372
414
  const theme = useTheme();
@@ -434,8 +476,6 @@ function getToolHeader(toolCall) {
434
476
  }
435
477
  }
436
478
  function summarizeToolResult(tc) {
437
- if (tc.resultCollapsed)
438
- return tc.isError ? "error output collapsed" : "result collapsed";
439
479
  if (tc.result === undefined)
440
480
  return "pending";
441
481
  const raw = tc.result.replace(/\r\n/g, "\n");
@@ -557,13 +597,12 @@ function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, show
557
597
  summaryColor = theme.success;
558
598
  }
559
599
  const editDetails = getEditDiffDetails(toolCall);
560
- const isEditDiff = editDetails !== null && toolCall.result !== undefined && !toolCall.resultCollapsed;
561
- const showSummary = !toolCall.resultCollapsed || waitingApproval;
600
+ const isEditDiff = editDetails !== null && toolCall.result !== undefined;
562
601
  // Only show the file preview once the tool actually executed. During the
563
602
  // streaming-args phase, args.content is incomplete and re-rendering the
564
603
  // entire body per delta both looks chaotic and breaks on partial escapes.
565
- const isWritePreview = toolCall.name === "write" && !toolCall.isError && toolCall.result !== undefined && !toolCall.resultCollapsed;
566
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: bulletColor, children: [glyph, " "] }), _jsx(Text, { bold: true, color: theme.toolName, children: name }), header && _jsxs(Text, { color: theme.muted, children: ["(", header, ")"] })] }), showSummary && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: summaryColor, children: ["\u23BF ", summary] }) })), toolCall.isError && toolCall.result && (_jsx(Box, { marginLeft: 4, flexDirection: "column", children: toolCall.result.replace(/\r\n/g, "\n").split("\n").slice(0, 6).map((line, i) => (_jsx(Text, { color: theme.error, children: line }, i))) })), isEditDiff && (_jsx(DiffBlock, { diff: editDetails.diff, terminalColumns: terminalColumns, maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint })), isWritePreview && (_jsx(WritePreview, { content: String(toolCall.args.content || ""), maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint })), !toolCall.isError && !isEditDiff && !isWritePreview && highlighted && (_jsx(OutputPreview, { text: highlighted, maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint }))] }));
604
+ const isWritePreview = toolCall.name === "write" && !toolCall.isError && toolCall.result !== undefined;
605
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: bulletColor, children: [glyph, " "] }), _jsx(Text, { bold: true, color: theme.toolName, children: name }), header && _jsxs(Text, { color: theme.muted, children: ["(", header, ")"] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: summaryColor, children: ["\u23BF ", summary] }) }), toolCall.isError && toolCall.result && (_jsx(Box, { marginLeft: 4, flexDirection: "column", children: toolCall.result.replace(/\r\n/g, "\n").split("\n").slice(0, 6).map((line, i) => (_jsx(Text, { color: theme.error, children: line }, i))) })), isEditDiff && (_jsx(DiffBlock, { diff: editDetails.diff, terminalColumns: terminalColumns, maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint })), isWritePreview && (_jsx(WritePreview, { content: String(toolCall.args.content || ""), maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint })), !toolCall.isError && !isEditDiff && !isWritePreview && highlighted && (_jsx(OutputPreview, { text: highlighted, maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint }))] }));
567
606
  }
568
607
  function SubagentToolDisplay({ toolCall, verbose, terminalColumns, compactTop, }) {
569
608
  const theme = useTheme();
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
+ import { END_SYNC, wrapSynchronizedOutput } from "./sync-output.js";
3
4
  import { App } from "./app.js";
4
5
  import { warmHighlighter } from "./code-highlight.js";
5
6
  export function createInkAppElement(agent, args, options, onExit) {
@@ -8,16 +9,17 @@ export function createInkAppElement(agent, args, options, onExit) {
8
9
  /**
9
10
  * Best-effort terminal restore for abnormal exits. Bubble renders into the
10
11
  * primary screen (no alt-screen, no mouse reporting) so the transcript flows
11
- * into the terminal's native scrollback — there is no global mouse/alt-screen
12
- * state to undo. We only make sure the cursor is visible again, mirroring
13
- * Ink's own teardown (idempotent when Ink already ran; load-bearing when it
14
- * didn't).
12
+ * into the terminal's native scrollback. We end synchronized output first —
13
+ * a crash between a frame's ?2026h and ?2026l would otherwise leave the
14
+ * terminal buffering until its own timeout — then restore the cursor,
15
+ * mirroring Ink's teardown (idempotent when Ink already ran; load-bearing
16
+ * when it didn't).
15
17
  */
16
18
  function restoreTerminal() {
17
19
  if (!process.stdout.isTTY)
18
20
  return;
19
21
  try {
20
- process.stdout.write("\x1b[?25h");
22
+ process.stdout.write(`${END_SYNC}\x1b[?25h`);
21
23
  }
22
24
  catch {
23
25
  // stdout may already be destroyed during shutdown
@@ -77,6 +79,9 @@ export async function runTui(agent, args, options = {}) {
77
79
  // and frees the arrow keys for composer history. Only the streaming tail
78
80
  // and the composer live in the repainting region at the bottom.
79
81
  alternateScreen: false,
82
+ // Frames composite atomically via synchronized output (CSI ?2026) —
83
+ // kills erase-then-repaint flicker without alt-screen.
84
+ stdout: wrapSynchronizedOutput(process.stdout),
80
85
  });
81
86
  try {
82
87
  await instance.waitUntilExit();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Synchronized-output wrapper (CSI ?2026, "sync updates").
3
+ *
4
+ * Every frame Ink writes gets bracketed in begin/end synchronized-output so
5
+ * the terminal composites it atomically instead of painting mid-erase state —
6
+ * the primary source of visible flicker during streaming. This is exactly
7
+ * what pi's main-screen renderer does per paint batch; alt-screen is NOT
8
+ * required for it.
9
+ *
10
+ * Terminals that don't implement mode 2026 ignore unknown DEC private modes,
11
+ * so emitting unconditionally is safe; BUBBLE_NO_SYNC_OUTPUT=1 is the escape
12
+ * hatch for anything that misbehaves. Only string chunks are bracketed —
13
+ * Ink writes frames as strings; binary writes pass through untouched.
14
+ */
15
+ export declare const BEGIN_SYNC = "\u001B[?2026h";
16
+ export declare const END_SYNC = "\u001B[?2026l";
17
+ export declare function wrapSynchronizedOutput(stream: NodeJS.WriteStream): NodeJS.WriteStream;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Synchronized-output wrapper (CSI ?2026, "sync updates").
3
+ *
4
+ * Every frame Ink writes gets bracketed in begin/end synchronized-output so
5
+ * the terminal composites it atomically instead of painting mid-erase state —
6
+ * the primary source of visible flicker during streaming. This is exactly
7
+ * what pi's main-screen renderer does per paint batch; alt-screen is NOT
8
+ * required for it.
9
+ *
10
+ * Terminals that don't implement mode 2026 ignore unknown DEC private modes,
11
+ * so emitting unconditionally is safe; BUBBLE_NO_SYNC_OUTPUT=1 is the escape
12
+ * hatch for anything that misbehaves. Only string chunks are bracketed —
13
+ * Ink writes frames as strings; binary writes pass through untouched.
14
+ */
15
+ export const BEGIN_SYNC = "\x1b[?2026h";
16
+ export const END_SYNC = "\x1b[?2026l";
17
+ export function wrapSynchronizedOutput(stream) {
18
+ if (!stream.isTTY || process.env.BUBBLE_NO_SYNC_OUTPUT === "1")
19
+ return stream;
20
+ return new Proxy(stream, {
21
+ get(target, prop) {
22
+ if (prop === "write") {
23
+ return (chunk, ...rest) => typeof chunk === "string" && chunk.length > 0
24
+ ? target.write(BEGIN_SYNC + chunk + END_SYNC, ...rest)
25
+ : target.write(chunk, ...rest);
26
+ }
27
+ const value = Reflect.get(target, prop, target);
28
+ return typeof value === "function" ? value.bind(target) : value;
29
+ },
30
+ set(target, prop, value) {
31
+ Reflect.set(target, prop, value);
32
+ return true;
33
+ },
34
+ });
35
+ }
@@ -56,7 +56,6 @@ export interface Theme {
56
56
  userMessageBorder: string;
57
57
  userMessageBg: string;
58
58
  userMessageText: string;
59
- userRail: string;
60
59
  /** Painted band backgrounds for diff lines inside user-message cards. */
61
60
  diffAdd: string;
62
61
  diffRemove: string;
@@ -46,7 +46,6 @@ export const darkTheme = {
46
46
  userMessageBorder: "#8A7FC6",
47
47
  userMessageBg: "#2a2a34",
48
48
  userMessageText: "#f3f3f7",
49
- userRail: "#8A7FC6",
50
49
  diffAdd: "#1a3d1a",
51
50
  diffRemove: "#3d1a1a",
52
51
  diffAddFg: "green",
@@ -95,7 +94,6 @@ export const lightTheme = {
95
94
  userMessageBorder: "#356FD2",
96
95
  userMessageBg: "#F1F3F0",
97
96
  userMessageText: "#234B93",
98
- userRail: "#356FD2",
99
97
  diffAdd: "#D7E8D8",
100
98
  diffRemove: "#F7DADC",
101
99
  diffAddFg: "#2F7D4A",
@@ -387,7 +387,7 @@ function isFailedSubagent(subagent) {
387
387
  return subagent.status === "failed" || subagent.status === "blocked" || subagent.status === "cancelled";
388
388
  }
389
389
  function isToolPending(tool) {
390
- return tool.result === undefined && tool.resultCollapsed !== true;
390
+ return tool.result === undefined;
391
391
  }
392
392
  function isDirectoryLikeGlob(pattern) {
393
393
  const normalized = pattern.trim();
package/dist/types.d.ts CHANGED
@@ -130,7 +130,6 @@ export interface ToolResultMetadata {
130
130
  searchSignature?: string;
131
131
  searchFamily?: string;
132
132
  reason?: string;
133
- arbiterNote?: string;
134
133
  diff?: string;
135
134
  addedLines?: number;
136
135
  removedLines?: number;
@@ -235,6 +234,15 @@ export interface ToolRegistryEntry extends ToolDefinition {
235
234
  * fan-out never share it (design v2 §2). Tools without it are shared as-is.
236
235
  */
237
236
  cloneForChild?: () => ToolRegistryEntry;
237
+ /**
238
+ * Optional availability predicate, evaluated on EVERY model call. When it
239
+ * returns false the tool is omitted from the provider's function list
240
+ * entirely — the model cannot see or call it — and reappears on the next
241
+ * call once the predicate flips. For state-gated tools (e.g. update_goal
242
+ * without an active goal) this removes the affordance itself instead of
243
+ * relying on description wording, which strong models ignore.
244
+ */
245
+ enabled?: () => boolean;
238
246
  /** Optional one-line summary for the Available tools section. */
239
247
  promptSnippet?: string;
240
248
  /** Optional tool-specific rules appended to the system prompt when this tool is active. */
@@ -314,6 +322,17 @@ export interface TokenUsage {
314
322
  reasoningTokens?: number;
315
323
  totalTokens?: number;
316
324
  }
325
+ /**
326
+ * Priced token usage (see model-pricing.ts). Lives here rather than in
327
+ * model-pricing.ts because turn_end events carry it and model-pricing
328
+ * already imports from this module.
329
+ */
330
+ export interface UsageCost {
331
+ currency: "USD" | "CNY";
332
+ cost: number;
333
+ /** True when the provider gave no cache breakdown and everything was billed at the miss rate. */
334
+ estimated: boolean;
335
+ }
317
336
  export interface Provider {
318
337
  streamChat(messages: ProviderMessage[], options: {
319
338
  model: string;
@@ -410,6 +429,7 @@ export type AgentEvent = {
410
429
  } | {
411
430
  type: "turn_end";
412
431
  usage?: TokenUsage;
432
+ cost?: UsageCost;
413
433
  willContinue?: boolean;
414
434
  } | {
415
435
  type: "context_recovered";