@bubblebrain-ai/bubble 0.0.45 → 0.0.47

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 (93) 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 +58 -29
  37. package/dist/oauth/openai-codex.js +13 -0
  38. package/dist/oauth/storage.d.ts +9 -2
  39. package/dist/oauth/storage.js +16 -9
  40. package/dist/orchestrator/default-hooks.d.ts +20 -0
  41. package/dist/orchestrator/default-hooks.js +47 -295
  42. package/dist/orchestrator/hooks.d.ts +6 -39
  43. package/dist/permission/mode.js +1 -1
  44. package/dist/prompt/reminders.d.ts +9 -35
  45. package/dist/prompt/reminders.js +30 -136
  46. package/dist/prompt/task-reminders.d.ts +0 -31
  47. package/dist/prompt/task-reminders.js +0 -87
  48. package/dist/provider-ai-sdk.d.ts +2 -0
  49. package/dist/provider-ai-sdk.js +1 -0
  50. package/dist/provider-anthropic.d.ts +2 -0
  51. package/dist/provider-anthropic.js +3 -1
  52. package/dist/provider-ark-responses.d.ts +2 -0
  53. package/dist/provider-ark-responses.js +1 -0
  54. package/dist/provider-registry.d.ts +13 -0
  55. package/dist/provider-registry.js +83 -0
  56. package/dist/provider-transform.js +37 -1
  57. package/dist/provider.d.ts +7 -0
  58. package/dist/provider.js +13 -1
  59. package/dist/sdk/index.d.ts +152 -0
  60. package/dist/sdk/index.js +400 -0
  61. package/dist/session.js +9 -1
  62. package/dist/slash-commands/commands.js +33 -19
  63. package/dist/tools/exit-plan-mode.js +8 -0
  64. package/dist/tui/detect-theme.d.ts +1 -0
  65. package/dist/tui/detect-theme.js +11 -5
  66. package/dist/tui-ink/app.d.ts +2 -6
  67. package/dist/tui-ink/app.js +11 -18
  68. package/dist/tui-ink/display-history.d.ts +0 -2
  69. package/dist/tui-ink/display-history.js +0 -52
  70. package/dist/tui-ink/footer.js +2 -2
  71. package/dist/tui-ink/message-list.js +62 -23
  72. package/dist/tui-ink/run.js +10 -5
  73. package/dist/tui-ink/sync-output.d.ts +17 -0
  74. package/dist/tui-ink/sync-output.js +35 -0
  75. package/dist/tui-ink/theme.d.ts +0 -1
  76. package/dist/tui-ink/theme.js +0 -2
  77. package/dist/tui-ink/trace-groups.js +1 -1
  78. package/dist/types.d.ts +21 -1
  79. package/package.json +16 -2
  80. package/dist/agent/discovery-barrier.d.ts +0 -21
  81. package/dist/agent/discovery-barrier.js +0 -173
  82. package/dist/agent/evidence-tracker.d.ts +0 -15
  83. package/dist/agent/evidence-tracker.js +0 -93
  84. package/dist/agent/execution-governor.d.ts +0 -36
  85. package/dist/agent/execution-governor.js +0 -218
  86. package/dist/agent/task-classifier.d.ts +0 -3
  87. package/dist/agent/task-classifier.js +0 -114
  88. package/dist/agent/task-size.d.ts +0 -9
  89. package/dist/agent/task-size.js +0 -33
  90. package/dist/agent/tool-arbiter.d.ts +0 -7
  91. package/dist/agent/tool-arbiter.js +0 -33
  92. package/dist/orchestrator/workflow.d.ts +0 -10
  93. package/dist/orchestrator/workflow.js +0 -22
@@ -987,7 +987,7 @@ const builtinSlashCommandEntries = [
987
987
  },
988
988
  {
989
989
  name: "mcp",
990
- description: "Manage MCP servers. Usage: /mcp [list|reconnect <name>]",
990
+ description: "Manage MCP servers. Usage: /mcp [list|tools <name>|reconnect <name>]",
991
991
  async handler(args, ctx) {
992
992
  const tokens = args.trim().split(/\s+/).filter(Boolean);
993
993
  const sub = tokens[0] ?? "list";
@@ -1009,46 +1009,60 @@ const builtinSlashCommandEntries = [
1009
1009
  }
1010
1010
  return `${name}: ${state.status.kind}`;
1011
1011
  }
1012
+ if (sub === "tools") {
1013
+ const name = tokens[1];
1014
+ if (!name)
1015
+ return "Usage: /mcp tools <server-name>";
1016
+ const state = ctx.mcpManager.getStates().find((s) => s.name === name);
1017
+ if (!state)
1018
+ return `Unknown MCP server: ${name}`;
1019
+ if (state.status.kind !== "connected") {
1020
+ return `${name} is not connected — no tools to list. Try /mcp reconnect ${name}.`;
1021
+ }
1022
+ const lines = [`Tools from ${name} (${state.status.tools.length}):`, ""];
1023
+ for (const tool of state.status.tools) {
1024
+ lines.push(`- \`${tool.name}\`${tool.description ? ` — ${tool.description.replace(/\s+/g, " ").slice(0, 100)}` : ""}`);
1025
+ }
1026
+ return lines.join("\n");
1027
+ }
1012
1028
  if (sub !== "list" && sub !== "") {
1013
- return `Unknown /mcp subcommand "${sub}". Use /mcp list or /mcp reconnect <name>.`;
1029
+ return `Unknown /mcp subcommand "${sub}". Use /mcp list, /mcp tools <name>, or /mcp reconnect <name>.`;
1014
1030
  }
1015
1031
  const states = ctx.mcpManager.getStates();
1016
1032
  if (states.length === 0) {
1017
1033
  return "No MCP servers configured. Add entries under `mcpServers` in ~/.bubble/settings.json or <cwd>/.bubble/settings.json.";
1018
1034
  }
1035
+ // Rendered as markdown in the TUI: each server is its own paragraph,
1036
+ // failures are bold + uppercase so they stand apart from healthy rows,
1037
+ // and tool lists stay collapsed behind /mcp tools <name>.
1019
1038
  const lines = ["MCP servers:"];
1020
1039
  for (const state of states) {
1021
- const transport = state.config.type;
1022
- const scope = state.scope;
1040
+ const meta = `${state.scope}/${state.config.type}`;
1041
+ lines.push("");
1023
1042
  if (state.status.kind === "connected") {
1024
- const info = state.status.serverInfo ? ` ${state.status.serverInfo.name}@${state.status.serverInfo.version}` : "";
1043
+ const info = state.status.serverInfo ? ` · ${state.status.serverInfo.name}@${state.status.serverInfo.version}` : "";
1025
1044
  const tn = state.status.tools.length;
1026
1045
  const pn = state.status.prompts.length;
1027
1046
  const counts = [`${tn} tool${tn === 1 ? "" : "s"}`];
1028
1047
  if (pn > 0)
1029
1048
  counts.push(`${pn} prompt${pn === 1 ? "" : "s"}`);
1030
- lines.push(` ✔ ${state.name} [${scope}/${transport}]${info} — ${counts.join(", ")}`);
1031
- for (const tool of state.status.tools) {
1032
- lines.push(` · ${tool.name}${tool.description ? ` — ${tool.description.replace(/\s+/g, " ").slice(0, 80)}` : ""}`);
1033
- }
1049
+ lines.push(`✔ ${state.name} — connected · ${counts.join(" · ")} (${meta}${info})`);
1034
1050
  if (pn > 0) {
1035
- lines.push(` prompts (invoke as /<name>):`);
1036
- for (const p of state.status.prompts) {
1037
- const argSig = p.arguments?.length
1038
- ? ` <${p.arguments.map((a) => (a.required ? a.name : `${a.name}?`)).join("> <")}>`
1039
- : "";
1040
- const cmdName = normalizeNameForMCP(p.name);
1041
- lines.push(` · /${cmdName}${argSig}${p.description ? ` — ${p.description.replace(/\s+/g, " ").slice(0, 70)}` : ""}`);
1042
- }
1051
+ const prompts = state.status.prompts.map((p) => `/${normalizeNameForMCP(p.name)}`);
1052
+ lines.push(` prompts: ${prompts.join(", ")}`);
1043
1053
  }
1044
1054
  }
1045
1055
  else if (state.status.kind === "failed") {
1046
- lines.push(` ✘ ${state.name} [${scope}/${transport}] — ${state.status.error}`);
1056
+ lines.push(`**✘ ${state.name} — UNABLE TO CONNECT** (${meta})`);
1057
+ lines.push(` ${state.status.error.replace(/\s+/g, " ").slice(0, 200)}`);
1058
+ lines.push(` retry: /mcp reconnect ${state.name}`);
1047
1059
  }
1048
1060
  else {
1049
- lines.push(` ○ ${state.name} [${scope}/${transport}] — disabled`);
1061
+ lines.push(`○ ${state.name} — disabled (${meta})`);
1050
1062
  }
1051
1063
  }
1064
+ lines.push("");
1065
+ lines.push("Details: /mcp tools <name> · /mcp reconnect <name>");
1052
1066
  return lines.join("\n");
1053
1067
  },
1054
1068
  },
@@ -11,6 +11,14 @@ export function createExitPlanModeTool(controller) {
11
11
  readOnly: true,
12
12
  effect: "read",
13
13
  requiresApproval: true,
14
+ // Outside plan mode the tool is not just useless, it is bait: models call
15
+ // it to "wrap up" an ordinary turn despite the description below saying
16
+ // not to (same failure seen with update_goal — wording loses to the
17
+ // trained close-out prior). Gating it out of the provider's function list
18
+ // removes the affordance; the predicate re-runs every model call, so
19
+ // entering plan mode surfaces it immediately. The runtime no-op in
20
+ // agent.ts stays as a backstop for a mode flip mid-turn.
21
+ enabled: () => controller.getMode() === "plan",
14
22
  description: "ONLY call this tool when the harness has told you via a runtime reminder that plan mode is ACTIVE. " +
15
23
  "Do NOT call it during ordinary work — in default mode you should just use the regular tools directly. " +
16
24
  "In plan mode: after investigating, call this with a concrete step-by-step plan so the user can approve, edit, or reject. " +
@@ -1,3 +1,4 @@
1
1
  export type ResolvedTheme = "light" | "dark";
2
2
  export declare function detectTerminalTheme(timeoutMs?: number): Promise<ResolvedTheme>;
3
+ export declare function parseColorFgBg(value: string | undefined): ResolvedTheme | null;
3
4
  export declare function themeFromMacOsAppearance(output: string | null | undefined): ResolvedTheme;
@@ -1,19 +1,25 @@
1
1
  import { execFileSync } from "node:child_process";
2
- export async function detectTerminalTheme(timeoutMs = 150) {
3
- const fromEnv = parseColorFgBg(process.env.COLORFGBG);
4
- if (fromEnv)
5
- return fromEnv;
2
+ export async function detectTerminalTheme(timeoutMs = 300) {
3
+ // OSC 11 asks the terminal for its actual background color right now — the
4
+ // only signal that tracks a theme switched after the shell started. The
5
+ // launch-time signals below are tie-breakers for terminals that don't
6
+ // answer: COLORFGBG is frozen at shell startup (and propagated stale by
7
+ // tmux/ssh), and the OS appearance describes the system, not the terminal
8
+ // profile. Ordering OSC 11 first is what keeps "auto" matching reality.
6
9
  if (process.stdout.isTTY && process.stdin.isTTY) {
7
10
  const fromOsc = await queryOsc11(timeoutMs);
8
11
  if (fromOsc)
9
12
  return fromOsc;
10
13
  }
14
+ const fromEnv = parseColorFgBg(process.env.COLORFGBG);
15
+ if (fromEnv)
16
+ return fromEnv;
11
17
  const fromOs = detectOsAppearanceTheme();
12
18
  if (fromOs)
13
19
  return fromOs;
14
20
  return "dark";
15
21
  }
16
- function parseColorFgBg(value) {
22
+ export function parseColorFgBg(value) {
17
23
  if (!value)
18
24
  return null;
19
25
  const parts = value.split(";");
@@ -15,6 +15,7 @@ import type { QuestionController } from "../question/index.js";
15
15
  import type { MemoryScope } from "../memory/index.js";
16
16
  import type { ExternalHookController } from "../hooks/controller.js";
17
17
  import type { GoalStore } from "../goal/store.js";
18
+ import { isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
18
19
  import type { ProcessManager } from "../tasks/manager.js";
19
20
  import type { PromotionChannel } from "../tasks/promotion.js";
20
21
  import type { ExternalRuntimeManager } from "../external-runtime/types.js";
@@ -70,11 +71,7 @@ export interface ExitSummary {
70
71
  /** Wall-clock duration of the session, in milliseconds. */
71
72
  wallMs: number;
72
73
  }
73
- /**
74
- * True when a user message is nothing but harness-injected internal blocks
75
- * (goal kicks, task wakes) — hidden live, so hidden on reconstruction too.
76
- */
77
- export declare function isInternalBlockOnlyContent(content: Message["content"]): boolean;
74
+ export { isInternalBlockOnlyContent };
78
75
  export declare function reconstructDisplayMessages(agentMessages: Message[]): DisplayMessage[];
79
76
  export declare const INK_LOCAL_SLASH_COMMANDS: readonly [{
80
77
  readonly name: "goal";
@@ -84,4 +81,3 @@ export declare const INK_LOCAL_SLASH_COMMANDS: readonly [{
84
81
  readonly description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])";
85
82
  }];
86
83
  export declare function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, processManager, tasksAutoResume, promotionChannel, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, externalRuntime, onExit }: AppProps): import("react/jsx-runtime").JSX.Element;
87
- export {};
@@ -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;
@@ -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;