@bubblebrain-ai/bubble 0.0.11 → 0.0.13

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 (160) hide show
  1. package/dist/agent/input-controller.d.ts +11 -0
  2. package/dist/agent/input-controller.js +30 -0
  3. package/dist/agent.d.ts +6 -4
  4. package/dist/agent.js +39 -2
  5. package/dist/feishu/agent-host/run-driver.js +13 -6
  6. package/dist/feishu/agent-host/runtime-deps.d.ts +2 -2
  7. package/dist/feishu/router/commands.js +2 -1
  8. package/dist/feishu/scope/session-binder.js +1 -1
  9. package/dist/feishu/serve.js +3 -3
  10. package/dist/main.js +78 -12
  11. package/dist/prompt/compose.js +3 -3
  12. package/dist/prompt/environment.js +2 -0
  13. package/dist/prompt/reminders.js +1 -1
  14. package/dist/provider-openai-codex.d.ts +8 -1
  15. package/dist/provider-openai-codex.js +33 -9
  16. package/dist/provider.d.ts +2 -0
  17. package/dist/session-title.d.ts +16 -0
  18. package/dist/session-title.js +134 -0
  19. package/dist/session-types.d.ts +5 -0
  20. package/dist/session.d.ts +5 -0
  21. package/dist/session.js +75 -9
  22. package/dist/skills/invocation.js +0 -18
  23. package/dist/skills/registry.d.ts +1 -0
  24. package/dist/skills/registry.js +2 -0
  25. package/dist/slash-commands/commands.js +29 -22
  26. package/dist/slash-commands/registry.js +1 -1
  27. package/dist/slash-commands/types.d.ts +10 -0
  28. package/dist/text-display.d.ts +3 -0
  29. package/dist/text-display.js +25 -0
  30. package/dist/tools/index.d.ts +1 -0
  31. package/dist/tools/index.js +3 -1
  32. package/dist/tools/skill-search.d.ts +10 -0
  33. package/dist/tools/skill-search.js +134 -0
  34. package/dist/tools/skill.js +1 -4
  35. package/dist/tui/clipboard.d.ts +1 -0
  36. package/dist/tui/clipboard.js +53 -0
  37. package/dist/tui/detect-theme.d.ts +2 -0
  38. package/dist/tui/detect-theme.js +87 -0
  39. package/dist/tui/display-history.d.ts +62 -0
  40. package/dist/tui/display-history.js +305 -0
  41. package/dist/tui/edit-diff.d.ts +11 -0
  42. package/dist/tui/edit-diff.js +52 -0
  43. package/dist/tui/escape-confirmation.d.ts +15 -0
  44. package/dist/tui/escape-confirmation.js +30 -0
  45. package/dist/tui/file-mentions.d.ts +29 -0
  46. package/dist/tui/file-mentions.js +174 -0
  47. package/dist/tui/global-key-router.d.ts +3 -0
  48. package/dist/tui/global-key-router.js +87 -0
  49. package/dist/tui/image-paste.d.ts +95 -0
  50. package/dist/tui/image-paste.js +505 -0
  51. package/dist/tui/input-history.d.ts +16 -0
  52. package/dist/tui/input-history.js +79 -0
  53. package/dist/tui/markdown-inline.d.ts +22 -0
  54. package/dist/tui/markdown-inline.js +68 -0
  55. package/dist/tui/markdown-theme-rules.d.ts +23 -0
  56. package/dist/tui/markdown-theme-rules.js +164 -0
  57. package/dist/tui/markdown-theme.d.ts +5 -0
  58. package/dist/tui/markdown-theme.js +27 -0
  59. package/dist/tui/opencode-spinner.d.ts +22 -0
  60. package/dist/tui/opencode-spinner.js +216 -0
  61. package/dist/tui/prompt-keybindings.d.ts +42 -0
  62. package/dist/tui/prompt-keybindings.js +35 -0
  63. package/dist/tui/recent-activity.d.ts +8 -0
  64. package/dist/tui/recent-activity.js +71 -0
  65. package/dist/tui/render-signature.d.ts +1 -0
  66. package/dist/tui/render-signature.js +7 -0
  67. package/dist/tui/run.d.ts +45 -0
  68. package/dist/tui/run.js +8816 -0
  69. package/dist/tui/session-display.d.ts +6 -0
  70. package/dist/tui/session-display.js +12 -0
  71. package/dist/tui/sidebar-mcp.d.ts +31 -0
  72. package/dist/tui/sidebar-mcp.js +62 -0
  73. package/dist/tui/sidebar-state.d.ts +12 -0
  74. package/dist/tui/sidebar-state.js +69 -0
  75. package/dist/tui/streaming-tool-args.d.ts +15 -0
  76. package/dist/tui/streaming-tool-args.js +30 -0
  77. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  78. package/dist/tui/tool-renderers/fallback.js +75 -0
  79. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  80. package/dist/tui/tool-renderers/registry.js +11 -0
  81. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  82. package/dist/tui/tool-renderers/subagent.js +135 -0
  83. package/dist/tui/tool-renderers/types.d.ts +36 -0
  84. package/dist/tui/tool-renderers/types.js +1 -0
  85. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  86. package/dist/tui/tool-renderers/write-preview.js +30 -0
  87. package/dist/tui/tool-renderers/write.d.ts +6 -0
  88. package/dist/tui/tool-renderers/write.js +88 -0
  89. package/dist/tui/trace-groups.d.ts +27 -0
  90. package/dist/tui/trace-groups.js +412 -0
  91. package/dist/tui/wordmark.d.ts +15 -0
  92. package/dist/tui/wordmark.js +179 -0
  93. package/dist/tui-ink/app.js +98 -70
  94. package/dist/tui-ink/input-box.d.ts +22 -1
  95. package/dist/tui-ink/input-box.js +105 -11
  96. package/dist/tui-ink/message-list.js +12 -3
  97. package/dist/tui-ink/model-picker.d.ts +18 -0
  98. package/dist/tui-ink/model-picker.js +80 -23
  99. package/dist/tui-ink/session-picker.js +5 -7
  100. package/dist/tui-ink/theme.d.ts +3 -9
  101. package/dist/tui-ink/theme.js +39 -45
  102. package/dist/tui-ink/welcome.js +22 -78
  103. package/dist/tui-opentui/app.d.ts +54 -0
  104. package/dist/tui-opentui/app.js +1363 -0
  105. package/dist/tui-opentui/approval/approval-dialog.d.ts +15 -0
  106. package/dist/tui-opentui/approval/approval-dialog.js +139 -0
  107. package/dist/tui-opentui/approval/diff-view.d.ts +9 -0
  108. package/dist/tui-opentui/approval/diff-view.js +43 -0
  109. package/dist/tui-opentui/approval/select.d.ts +37 -0
  110. package/dist/tui-opentui/approval/select.js +91 -0
  111. package/dist/tui-opentui/detect-theme.d.ts +2 -0
  112. package/dist/tui-opentui/detect-theme.js +87 -0
  113. package/dist/tui-opentui/display-history.d.ts +55 -0
  114. package/dist/tui-opentui/display-history.js +129 -0
  115. package/dist/tui-opentui/edit-diff.d.ts +11 -0
  116. package/dist/tui-opentui/edit-diff.js +52 -0
  117. package/dist/tui-opentui/feedback-dialog.d.ts +21 -0
  118. package/dist/tui-opentui/feedback-dialog.js +164 -0
  119. package/dist/tui-opentui/feishu-setup-picker.d.ts +7 -0
  120. package/dist/tui-opentui/feishu-setup-picker.js +272 -0
  121. package/dist/tui-opentui/file-mentions.d.ts +29 -0
  122. package/dist/tui-opentui/file-mentions.js +174 -0
  123. package/dist/tui-opentui/footer.d.ts +26 -0
  124. package/dist/tui-opentui/footer.js +40 -0
  125. package/dist/tui-opentui/image-paste.d.ts +54 -0
  126. package/dist/tui-opentui/image-paste.js +288 -0
  127. package/dist/tui-opentui/input-box.d.ts +34 -0
  128. package/dist/tui-opentui/input-box.js +471 -0
  129. package/dist/tui-opentui/input-history.d.ts +16 -0
  130. package/dist/tui-opentui/input-history.js +79 -0
  131. package/dist/tui-opentui/markdown.d.ts +66 -0
  132. package/dist/tui-opentui/markdown.js +127 -0
  133. package/dist/tui-opentui/message-list.d.ts +31 -0
  134. package/dist/tui-opentui/message-list.js +125 -0
  135. package/dist/tui-opentui/model-picker.d.ts +63 -0
  136. package/dist/tui-opentui/model-picker.js +450 -0
  137. package/dist/tui-opentui/plan-confirm.d.ts +9 -0
  138. package/dist/tui-opentui/plan-confirm.js +124 -0
  139. package/dist/tui-opentui/question-dialog.d.ts +10 -0
  140. package/dist/tui-opentui/question-dialog.js +110 -0
  141. package/dist/tui-opentui/recent-activity.d.ts +8 -0
  142. package/dist/tui-opentui/recent-activity.js +71 -0
  143. package/dist/tui-opentui/run-session-picker.d.ts +10 -0
  144. package/dist/tui-opentui/run-session-picker.js +28 -0
  145. package/dist/tui-opentui/run.d.ts +38 -0
  146. package/dist/tui-opentui/run.js +48 -0
  147. package/dist/tui-opentui/session-picker.d.ts +12 -0
  148. package/dist/tui-opentui/session-picker.js +120 -0
  149. package/dist/tui-opentui/theme.d.ts +89 -0
  150. package/dist/tui-opentui/theme.js +157 -0
  151. package/dist/tui-opentui/todos.d.ts +9 -0
  152. package/dist/tui-opentui/todos.js +45 -0
  153. package/dist/tui-opentui/trace-groups.d.ts +27 -0
  154. package/dist/tui-opentui/trace-groups.js +412 -0
  155. package/dist/tui-opentui/use-terminal-size.d.ts +4 -0
  156. package/dist/tui-opentui/use-terminal-size.js +5 -0
  157. package/dist/tui-opentui/welcome.d.ts +25 -0
  158. package/dist/tui-opentui/welcome.js +77 -0
  159. package/dist/types.d.ts +24 -0
  160. package/package.json +5 -1
@@ -4,7 +4,7 @@ import { Box, Text, useApp, useInput } from "ink";
4
4
  import { AgentAbortError } from "../agent.js";
5
5
  import { registry as slashRegistry } from "../slash-commands/index.js";
6
6
  import { UserConfig, maskKey } from "../config.js";
7
- import { InputBox, isCtrlCInput } from "./input-box.js";
7
+ import { createPastedContentMarker, InputBox, isCtrlCInput, shouldCollapsePastedContent, } from "./input-box.js";
8
8
  import { MessageList } from "./message-list.js";
9
9
  import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
10
10
  import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
@@ -65,7 +65,9 @@ function reconstructDisplayMessages(agentMessages) {
65
65
  result.push({
66
66
  key: nextDisplayMessageKey("user"),
67
67
  role: "user",
68
- content: typeof m.content === "string" ? m.content : "(multimedia)",
68
+ content: typeof m.content === "string"
69
+ ? (shouldCollapsePastedContent(m.content) ? createPastedContentMarker(m.content) : m.content)
70
+ : "(multimedia)",
69
71
  });
70
72
  }
71
73
  else if (m.role === "assistant") {
@@ -178,9 +180,40 @@ function withMessageKey(message) {
178
180
  const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
179
181
  return { ...message, key: nextDisplayMessageKey(prefix) };
180
182
  }
181
- const STREAMING_STATIC_FLUSH_MIN_CHARS = 5000;
182
- const STREAMING_STATIC_FLUSH_TARGET_CHARS = 3600;
183
- const STREAMING_STATIC_FLUSH_MIN_TAIL = 700;
183
+ // Keep the live (non-Static) region small so non-GPU terminals (xterm.js DOM
184
+ // renderer, ssh into a basic terminal, tmux without GPU) don't flicker when
185
+ // Ink re-reconciles the streaming block on every token. Flushing earlier and
186
+ // in smaller chunks shifts most of the answer into terminal scrollback, where
187
+ // it's a one-time write that doesn't get re-rendered.
188
+ const STREAMING_STATIC_FLUSH_MIN_CHARS = 600;
189
+ const STREAMING_STATIC_FLUSH_TARGET_CHARS = 400;
190
+ const STREAMING_STATIC_FLUSH_MIN_TAIL = 120;
191
+ /**
192
+ * True iff `prefix` ends inside an open ```/~~~ fenced code block. Splitting
193
+ * the streaming buffer at such a point would let the flushed half render
194
+ * without its closing fence — `MarkdownContent` would then treat the body as
195
+ * plain prose and the trailing half would render as an isolated code block
196
+ * with no opener. Fence delimiters of different families don't close each
197
+ * other (a `~~~` inside a ``` block is just text). We use a permissive
198
+ * "line starts with three or more of the same char" rule, ignoring the info
199
+ * string — that's enough to spot when we're mid-block.
200
+ */
201
+ function endsInsideUnclosedCodeFence(prefix) {
202
+ let openMarker = null;
203
+ for (const rawLine of prefix.split("\n")) {
204
+ const line = rawLine.replace(/^ {0,3}/, "");
205
+ if (openMarker === null) {
206
+ if (line.startsWith("```"))
207
+ openMarker = "`";
208
+ else if (line.startsWith("~~~"))
209
+ openMarker = "~";
210
+ }
211
+ else if (line.startsWith(openMarker.repeat(3))) {
212
+ openMarker = null;
213
+ }
214
+ }
215
+ return openMarker !== null;
216
+ }
184
217
  function findStreamingStaticFlushIndex(content) {
185
218
  if (content.length < STREAMING_STATIC_FLUSH_MIN_CHARS)
186
219
  return -1;
@@ -190,12 +223,20 @@ function findStreamingStaticFlushIndex(content) {
190
223
  const search = content.slice(0, upper);
191
224
  const paragraphBreak = search.lastIndexOf("\n\n");
192
225
  if (paragraphBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
193
- return paragraphBreak + 2;
226
+ const splitIndex = paragraphBreak + 2;
227
+ if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
228
+ return splitIndex;
229
+ }
194
230
  }
195
231
  const lineBreak = search.lastIndexOf("\n");
196
232
  if (lineBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
197
- return lineBreak + 1;
233
+ const splitIndex = lineBreak + 1;
234
+ if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
235
+ return splitIndex;
236
+ }
198
237
  }
238
+ // Inside an open code fence: hold off flushing until the closing fence
239
+ // arrives. The live region grows a bit, but Markdown rendering stays correct.
199
240
  return -1;
200
241
  }
201
242
  function cloneDisplayPart(part) {
@@ -281,6 +322,8 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
281
322
  const [pendingQuestion, setPendingQuestion] = useState(null);
282
323
  const [pendingFeedback, setPendingFeedback] = useState(null);
283
324
  const [pickerMode, setPickerMode] = useState(null);
325
+ const [cursorResetEpoch, setCursorResetEpoch] = useState(0);
326
+ const [composerDraft, setComposerDraft] = useState(null);
284
327
  const [keyProviderId, setKeyProviderId] = useState(null);
285
328
  const [verboseTrace, setVerboseTrace] = useState(false);
286
329
  const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
@@ -455,7 +498,6 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
455
498
  thinkingLevel: overrides?.thinkingLevel ?? agent.thinking,
456
499
  mode: overrides?.mode ?? agent.mode,
457
500
  workingDir: args.cwd,
458
- skills: safeSkillRegistry?.summaries() ?? [],
459
501
  }));
460
502
  }, [agent, args.cwd, safeRegistry, safeSkillRegistry]);
461
503
  useInput((input, key) => {
@@ -486,7 +528,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
486
528
  rebuildSystemPrompt({ thinkingLevel: nextLevel });
487
529
  userConfig.setDefaultThinkingLevel(nextLevel);
488
530
  setThinkingLevel(nextLevel);
489
- sessionManager?.setMetadata({ model: agent.model, thinkingLevel: nextLevel, reasoningEffort: nextLevel });
531
+ sessionManager?.updateMetadata({ model: agent.model, thinkingLevel: nextLevel, reasoningEffort: nextLevel });
490
532
  sessionManager?.appendMarker("thinking_level_switch", nextLevel);
491
533
  return;
492
534
  }
@@ -526,6 +568,19 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
526
568
  }
527
569
  setPickerMode(mode);
528
570
  }, []);
571
+ const closePicker = useCallback(() => {
572
+ setPickerMode(null);
573
+ setCursorResetEpoch((epoch) => epoch + 1);
574
+ }, []);
575
+ const fillComposer = useCallback((text) => {
576
+ setComposerDraft((current) => ({
577
+ text,
578
+ epoch: (current?.epoch ?? 0) + 1,
579
+ }));
580
+ }, []);
581
+ const clearComposerDraft = useCallback(() => {
582
+ setComposerDraft(null);
583
+ }, []);
529
584
  const openFeedback = useCallback((initialDescription) => {
530
585
  const base = collectFeedback(agent, { description: "" });
531
586
  const { description: _drop, ...rest } = base;
@@ -542,7 +597,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
542
597
  const provider = safeRegistry.getConfigured().find((item) => item.id === providerId);
543
598
  if (!provider?.apiKey || !createProvider) {
544
599
  addMessage("error", `Provider ${providerId} is not configured or has no active credentials.`);
545
- setPickerMode(null);
600
+ closePicker();
546
601
  return;
547
602
  }
548
603
  const modelId = model.includes(":") ? model.split(":").slice(1).join(":") : model;
@@ -556,17 +611,16 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
556
611
  configuredModelId: model,
557
612
  thinkingLevel: agent.thinking,
558
613
  workingDir: args.cwd,
559
- skills: safeSkillRegistry?.summaries() ?? [],
560
614
  }));
561
615
  userConfig.pushRecentModel(model);
562
616
  setThinkingLevel(agent.thinking);
563
- sessionManager?.setMetadata({ model, thinkingLevel: agent.thinking, reasoningEffort: agent.thinking });
617
+ sessionManager?.updateMetadata({ model, thinkingLevel: agent.thinking, reasoningEffort: agent.thinking });
564
618
  sessionManager?.appendMarker("model_switch", model);
565
619
  addMessage("assistant", `Model switched to ${displayModel(model)}.`);
566
- setPickerMode(null);
620
+ closePicker();
567
621
  };
568
622
  void run();
569
- }, [agent, addMessage, sessionManager, userConfig, safeRegistry, createProvider]);
623
+ }, [agent, addMessage, closePicker, sessionManager, userConfig, safeRegistry, createProvider]);
570
624
  const handleProviderSelect = useCallback(async (providerId) => {
571
625
  await safeRegistry.prepareProvider(providerId);
572
626
  const configured = safeRegistry.getConfigured();
@@ -574,7 +628,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
574
628
  const builtin = BUILTIN_PROVIDERS.find((x) => x.id === providerId);
575
629
  if (!p && !builtin) {
576
630
  addMessage("error", `Provider ${providerId} not found.`);
577
- setPickerMode(null);
631
+ closePicker();
578
632
  return;
579
633
  }
580
634
  if (!p?.apiKey) {
@@ -590,21 +644,21 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
590
644
  agent.setProvider(createProvider(providerId, p.apiKey, p.baseURL));
591
645
  agent.providerId = providerId;
592
646
  addMessage("assistant", `Switched to provider ${p.name}. Use /model to pick a model.`);
593
- setPickerMode(null);
594
- }, [addMessage, agent, createProvider, safeRegistry]);
647
+ closePicker();
648
+ }, [addMessage, agent, closePicker, createProvider, safeRegistry]);
595
649
  const handleProviderAddSelect = useCallback((providerId) => {
596
650
  const ok = safeRegistry.addProvider(providerId, "");
597
651
  if (!ok) {
598
652
  addMessage("error", `Provider ${providerId} could not be added.`);
599
- setPickerMode(null);
653
+ closePicker();
600
654
  return;
601
655
  }
602
656
  safeRegistry.setDefault(providerId);
603
657
  setKeyProviderId(providerId);
604
658
  setPickerMode("key");
605
- }, [addMessage, safeRegistry]);
659
+ }, [addMessage, closePicker, safeRegistry]);
606
660
  const handleLoginProviderSelect = useCallback(async (providerId) => {
607
- setPickerMode(null);
661
+ closePicker();
608
662
  const command = `/login ${providerId}`;
609
663
  const { handled, result } = await slashRegistry.execute(command, {
610
664
  agent,
@@ -635,9 +689,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
635
689
  if (handled && result) {
636
690
  addMessage("assistant", result);
637
691
  }
638
- }, [agent, addMessage, clearMessages, createProvider, exit, openPicker, safeRegistry, sessionManager]);
692
+ }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, openPicker, safeRegistry, sessionManager]);
639
693
  const handleLogoutProviderSelect = useCallback(async (providerId) => {
640
- setPickerMode(null);
694
+ closePicker();
641
695
  const command = `/logout ${providerId}`;
642
696
  const { handled, result } = await slashRegistry.execute(command, {
643
697
  agent,
@@ -668,12 +722,12 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
668
722
  if (handled && result) {
669
723
  addMessage("assistant", result);
670
724
  }
671
- }, [agent, addMessage, clearMessages, createProvider, exit, openPicker, safeRegistry, sessionManager]);
725
+ }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, openPicker, safeRegistry, sessionManager]);
672
726
  const handleKeySubmit = useCallback((key) => {
673
727
  const targetId = keyProviderId || safeRegistry.getDefault()?.id;
674
728
  if (!targetId) {
675
729
  addMessage("error", "No provider selected.");
676
- setPickerMode(null);
730
+ closePicker();
677
731
  setKeyProviderId(null);
678
732
  return;
679
733
  }
@@ -684,12 +738,13 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
684
738
  agent.providerId = targetId;
685
739
  }
686
740
  addMessage("assistant", `API key updated for ${p?.name || targetId} to ${maskKey(key)}.`);
687
- setPickerMode(null);
741
+ closePicker();
688
742
  setKeyProviderId(null);
689
- }, [addMessage, agent, createProvider, keyProviderId, safeRegistry]);
743
+ }, [addMessage, agent, closePicker, createProvider, keyProviderId, safeRegistry]);
690
744
  const handleSubmit = useCallback(async (payload) => {
691
745
  const normalized = typeof payload === "string" ? { text: payload, images: [] } : payload;
692
746
  const input = normalized.text;
747
+ const displayInput = normalized.displayText ?? input;
693
748
  const images = normalized.images;
694
749
  if (!input.trim() && images.length === 0)
695
750
  return;
@@ -966,7 +1021,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
966
1021
  };
967
1022
  // Slash commands and skill invocations drop any attached images —
968
1023
  // they're meant for pure command routing.
969
- if (input.startsWith("/")) {
1024
+ if (displayInput.startsWith("/")) {
970
1025
  // Fast-path `/quit` and `/exit` before slash-registry / skill
971
1026
  // resolution. This guarantees a literal "/quit" always exits even if
972
1027
  // a skill or alias of the same name is later registered. The
@@ -979,7 +1034,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
979
1034
  }
980
1035
  const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
981
1036
  if (skillInvocation) {
982
- await runAgentInput(skillInvocation.actualPrompt, input);
1037
+ await runAgentInput(skillInvocation.actualPrompt, displayInput);
983
1038
  return;
984
1039
  }
985
1040
  const { handled, result, inject } = await slashRegistry.execute(input, {
@@ -1033,7 +1088,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1033
1088
  }
1034
1089
  }
1035
1090
  if (inject) {
1036
- await runAgentInput(inject, input);
1091
+ await runAgentInput(inject, displayInput);
1037
1092
  }
1038
1093
  return;
1039
1094
  }
@@ -1054,7 +1109,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1054
1109
  })),
1055
1110
  ]
1056
1111
  : expansion.text;
1057
- await runAgentInput(agentInput, input, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
1112
+ await runAgentInput(agentInput, displayInput, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
1058
1113
  }, [addMessage, agent, args.cwd, openPicker, createProvider, safeRegistry, safeSkillRegistry, updateDisplayMessages]);
1059
1114
  const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
1060
1115
  const keyTarget = keyProviderId
@@ -1079,7 +1134,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1079
1134
  const mcpConnectedCount = mcpStates.filter((state) => state.status.kind === "connected").length;
1080
1135
  const hasAgentsFile = useMemo(() => existsSync(join(args.cwd, "AGENTS.md")) || existsSync(join(args.cwd, ".bubble", "AGENTS.md")), [args.cwd]);
1081
1136
  const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, modelLabel: agent.model ? displayModel(agent.model) : undefined, cwd: friendlyCwd(args.cwd), tips: buildTips(agent, safeRegistry), skillsCount: safeSkillRegistry.summaries().length, mcpConnectedCount: mcpConnectedCount, mcpTotalCount: mcpStates.length, hasAgentsFile: hasAgentsFile })) : null;
1082
- return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }, clearEpoch), pickerMode === "model" && (_jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: () => setPickerMode(null) })), pickerMode === "provider" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1137
+ return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }, clearEpoch), pickerMode === "model" && (_jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker })), pickerMode === "provider" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1083
1138
  .filter((p) => isUserVisibleProvider(p.id))
1084
1139
  .map((p) => {
1085
1140
  const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
@@ -1089,50 +1144,23 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1089
1144
  name: `${p.name} [${configuredLabel}]`,
1090
1145
  enabled: true,
1091
1146
  };
1092
- }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: () => setPickerMode(null) })), pickerMode === "provider-add" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1147
+ }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker })), pickerMode === "provider-add" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1093
1148
  .filter((p) => isUserVisibleProvider(p.id))
1094
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: () => setPickerMode(null), title: "Add Provider" })), pickerMode === "login" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1149
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" })), pickerMode === "login" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1095
1150
  .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1096
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: () => setPickerMode(null), title: "Select Login Provider" })), pickerMode === "logout" && (_jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1151
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" })), pickerMode === "logout" && (_jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1097
1152
  .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1098
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: () => setPickerMode(null), title: "Select Logout Provider" })), pickerMode === "key" && keyTarget && (_jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1099
- setPickerMode(null);
1153
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" })), pickerMode === "key" && keyTarget && (_jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1154
+ closePicker();
1100
1155
  setKeyProviderId(null);
1101
- } })), pickerMode === "skill" && (_jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: async (name) => {
1102
- setPickerMode(null);
1103
- const { handled, result } = await slashRegistry.execute(`/skill ${name}`, {
1104
- agent,
1105
- addMessage,
1106
- clearMessages,
1107
- cwd: args.cwd,
1108
- exit: () => { requestExit(); },
1109
- sessionManager,
1110
- createProvider: createProvider ?? (() => {
1111
- throw new Error("Provider creation not available");
1112
- }),
1113
- openPicker,
1114
- openFeedback,
1115
- registry: safeRegistry,
1116
- skillRegistry: safeSkillRegistry,
1117
- bashAllowlist,
1118
- settingsManager,
1119
- lspService,
1120
- mcpManager,
1121
- flushMemory,
1122
- runMemoryCompaction,
1123
- runMemorySummary,
1124
- runMemoryRefresh,
1125
- getThemeMode: () => themeMode,
1126
- getResolvedTheme: () => themeResolved,
1127
- setThemeMode: applyThemeMode,
1128
- });
1129
- if (handled && result)
1130
- addMessage("assistant", result);
1131
- }, onCancel: () => setPickerMode(null) })), pickerMode === "feishu-setup" && (_jsx(FeishuSetupPicker, { onComplete: (summary) => {
1132
- setPickerMode(null);
1156
+ } })), pickerMode === "skill" && (_jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1157
+ fillComposer(`/${name} `);
1158
+ closePicker();
1159
+ }, onCancel: closePicker })), pickerMode === "feishu-setup" && (_jsx(FeishuSetupPicker, { onComplete: (summary) => {
1160
+ closePicker();
1133
1161
  addMessage("assistant", summary);
1134
1162
  }, onCancel: () => {
1135
- setPickerMode(null);
1163
+ closePicker();
1136
1164
  addMessage("assistant", "已取消 Feishu setup。");
1137
1165
  } }))] }), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1138
1166
  const resolve = pendingPlan.resolve;
@@ -1161,7 +1189,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1161
1189
  else if (result.kind === "error") {
1162
1190
  addMessage("error", `Feedback failed: ${result.message}`);
1163
1191
  }
1164
- } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, disabled: isRunning || !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({
1192
+ } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, disabled: isRunning || !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({
1165
1193
  cwd: args.cwd,
1166
1194
  providerId: agent.providerId || safeRegistry.getDefault()?.id || "unknown",
1167
1195
  model: displayModel(agent.model) || "no model",
@@ -1,18 +1,36 @@
1
1
  import type { SkillRegistry } from "../skills/registry.js";
2
2
  import { type ImageAttachment } from "./image-paste.js";
3
3
  export interface SubmitPayload {
4
+ /** Fully-expanded text sent to the agent. */
4
5
  text: string;
6
+ /** Text shown in the composer/transcript when it differs from the real text. */
7
+ displayText?: string;
5
8
  images: ImageAttachment[];
6
9
  }
7
10
  interface InputBoxProps {
8
11
  onSubmit: (payload: SubmitPayload) => void;
9
12
  onPasteNotice?: (notice: string) => void;
10
13
  disabled?: boolean;
14
+ cursorResetEpoch?: number;
15
+ draftText?: string;
16
+ draftEpoch?: number;
17
+ onDraftApplied?: () => void;
11
18
  skillRegistry?: SkillRegistry;
12
19
  terminalColumns: number;
13
20
  cwd: string;
14
21
  }
22
+ export interface PastedContentReference {
23
+ marker: string;
24
+ content: string;
25
+ }
15
26
  export declare function needsCursorRowCompensation(nextOutputHeight: number, viewportRows: number, previousOutputHeight: number | null): boolean;
27
+ export declare function resolveCursorRowCompensation(input: {
28
+ sameRenderedFrame: boolean;
29
+ previousRowCompensation: number;
30
+ nextOutputHeight: number;
31
+ viewportRows: number;
32
+ previousOutputHeight: number | null;
33
+ }): number;
16
34
  export declare function isCtrlCInput(input: string, key: {
17
35
  ctrl?: boolean;
18
36
  }): boolean;
@@ -40,5 +58,8 @@ export declare function insertNewlineAtCursor(text: string, cursor: number): {
40
58
  text: string;
41
59
  cursor: number;
42
60
  };
43
- export declare function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, terminalColumns, cwd }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
61
+ export declare function shouldCollapsePastedContent(text: string): boolean;
62
+ export declare function createPastedContentMarker(content: string): string;
63
+ export declare function expandPastedContentMarkers(displayText: string, references: PastedContentReference[]): string;
64
+ export declare function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
44
65
  export {};
@@ -14,6 +14,8 @@ const MAX_VISIBLE_LINES = 6;
14
14
  const PADDING_X = 1;
15
15
  const PROMPT = " > ";
16
16
  const MAX_VISIBLE_SUGGESTIONS = 8;
17
+ const LONG_PASTE_CHAR_THRESHOLD = 1000;
18
+ const LONG_PASTE_LINE_THRESHOLD = 20;
17
19
  export function needsCursorRowCompensation(nextOutputHeight, viewportRows, previousOutputHeight) {
18
20
  const hadPreviousFrame = previousOutputHeight !== null && previousOutputHeight > 0;
19
21
  const isFullscreen = nextOutputHeight >= viewportRows;
@@ -27,6 +29,11 @@ export function needsCursorRowCompensation(nextOutputHeight, viewportRows, previ
27
29
  // line below the output, so pass y+1 in those cases.
28
30
  return isFullscreen || wasOverflowing || (isOverflowing && hadPreviousFrame) || isLeavingFullscreen;
29
31
  }
32
+ export function resolveCursorRowCompensation(input) {
33
+ if (input.sameRenderedFrame)
34
+ return input.previousRowCompensation;
35
+ return needsCursorRowCompensation(input.nextOutputHeight, input.viewportRows, input.previousOutputHeight) ? 1 : 0;
36
+ }
30
37
  export function isCtrlCInput(input, key) {
31
38
  return input === "\x03" || (key.ctrl === true && input.toLowerCase() === "c");
32
39
  }
@@ -141,7 +148,42 @@ export function insertNewlineAtCursor(text, cursor) {
141
148
  cursor: clampedCursor + 1,
142
149
  };
143
150
  }
144
- export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, terminalColumns, cwd }) {
151
+ export function shouldCollapsePastedContent(text) {
152
+ if (text.length >= LONG_PASTE_CHAR_THRESHOLD)
153
+ return true;
154
+ return text.split("\n").length >= LONG_PASTE_LINE_THRESHOLD;
155
+ }
156
+ export function createPastedContentMarker(content) {
157
+ return `[Pasted Content ${content.length} chars]`;
158
+ }
159
+ export function expandPastedContentMarkers(displayText, references) {
160
+ if (references.length === 0 || displayText.length === 0)
161
+ return displayText;
162
+ let expanded = "";
163
+ let index = 0;
164
+ const used = new Set();
165
+ while (index < displayText.length) {
166
+ let matched = -1;
167
+ for (let i = 0; i < references.length; i++) {
168
+ const ref = references[i];
169
+ if (!used.has(i) && displayText.startsWith(ref.marker, index)) {
170
+ matched = i;
171
+ break;
172
+ }
173
+ }
174
+ if (matched >= 0) {
175
+ const ref = references[matched];
176
+ expanded += ref.content;
177
+ index += ref.marker.length;
178
+ used.add(matched);
179
+ continue;
180
+ }
181
+ expanded += displayText[index];
182
+ index += 1;
183
+ }
184
+ return expanded;
185
+ }
186
+ export function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, terminalColumns, cwd, }) {
145
187
  const theme = useTheme();
146
188
  const width = terminalColumns;
147
189
  const [text, setText] = useState("");
@@ -149,6 +191,7 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
149
191
  const [selectedIndex, setSelectedIndex] = useState(0);
150
192
  const [projectFiles, setProjectFiles] = useState(null);
151
193
  const [attachments, setAttachments] = useState([]);
194
+ const [pastedContentRefs, setPastedContentRefs] = useState([]);
152
195
  const [history, setHistory] = useState(() => loadHistorySync());
153
196
  const [historyIndex, setHistoryIndex] = useState(null);
154
197
  const historyDraftRef = useRef("");
@@ -283,7 +326,14 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
283
326
  const imageTokens = tokens.filter(isImageFilePath);
284
327
  if (imageTokens.length === 0) {
285
328
  // Plain text paste — insert into the input at the cursor.
286
- insertTextAtCursor(clean);
329
+ if (shouldCollapsePastedContent(clean)) {
330
+ const marker = createPastedContentMarker(clean);
331
+ setPastedContentRefs((prev) => [...prev, { marker, content: clean }]);
332
+ insertTextAtCursor(marker);
333
+ }
334
+ else {
335
+ insertTextAtCursor(clean);
336
+ }
287
337
  clearPending();
288
338
  return;
289
339
  }
@@ -337,20 +387,28 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
337
387
  setSelectedIndex(0);
338
388
  };
339
389
  const submitInput = (submittedText) => {
340
- if (submittedText.trim().length === 0 && attachments.length === 0)
390
+ const expandedText = expandPastedContentMarkers(submittedText, pastedContentRefs);
391
+ if (expandedText.trim().length === 0 && attachments.length === 0)
341
392
  return;
342
- onSubmit({ text: submittedText, images: attachments });
343
- if (submittedText.trim().length > 0) {
344
- const nextHistory = pushHistoryEntry(history, submittedText);
393
+ onSubmit({
394
+ text: expandedText,
395
+ displayText: expandedText === submittedText ? undefined : submittedText,
396
+ images: attachments,
397
+ });
398
+ // A collapsed marker cannot be safely replayed from history once its
399
+ // in-memory paste reference is gone; skip those entries instead.
400
+ if (expandedText.trim().length > 0 && expandedText === submittedText) {
401
+ const nextHistory = pushHistoryEntry(history, expandedText);
345
402
  if (nextHistory !== history) {
346
403
  setHistory(nextHistory);
347
- appendHistoryEntry(submittedText);
404
+ appendHistoryEntry(expandedText);
348
405
  }
349
406
  }
350
407
  setText("");
351
408
  setCursor(0);
352
409
  setSelectedIndex(0);
353
410
  setAttachments([]);
411
+ setPastedContentRefs([]);
354
412
  setHistoryIndex(null);
355
413
  historyDraftRef.current = "";
356
414
  };
@@ -501,6 +559,7 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
501
559
  setHistoryIndex(result.index);
502
560
  historyDraftRef.current = result.draft;
503
561
  setSelectedIndex(0);
562
+ setPastedContentRefs([]);
504
563
  }
505
564
  return;
506
565
  }
@@ -516,6 +575,7 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
516
575
  setHistoryIndex(result.index);
517
576
  historyDraftRef.current = result.draft;
518
577
  setSelectedIndex(0);
578
+ setPastedContentRefs([]);
519
579
  }
520
580
  return;
521
581
  }
@@ -537,9 +597,40 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
537
597
  const previousViewportRowsRef = useRef(null);
538
598
  const previousInputFrameSignatureRef = useRef(null);
539
599
  const previousRowCompensationRef = useRef(0);
600
+ const lastCursorResetEpochRef = useRef(null);
601
+ const lastDraftEpochRef = useRef(null);
540
602
  const lastWidthRef = useRef(null);
541
603
  const { setCursorPosition } = useCursor();
542
604
  const { stdout } = useStdout();
605
+ const [cursorTick, setCursorTick] = useState(0);
606
+ useLayoutEffect(() => {
607
+ const isInitialMount = lastCursorResetEpochRef.current === null;
608
+ const shouldReset = !isInitialMount || cursorResetEpoch > 0;
609
+ lastCursorResetEpochRef.current = cursorResetEpoch;
610
+ if (!shouldReset)
611
+ return;
612
+ previousOutputHeightRef.current = null;
613
+ previousViewportRowsRef.current = null;
614
+ previousInputFrameSignatureRef.current = null;
615
+ previousRowCompensationRef.current = 0;
616
+ lastCursorRef.current = null;
617
+ setCursorPosition(undefined);
618
+ setCursorTick((t) => t + 1);
619
+ }, [cursorResetEpoch, setCursorPosition]);
620
+ useLayoutEffect(() => {
621
+ if (lastDraftEpochRef.current === draftEpoch)
622
+ return;
623
+ lastDraftEpochRef.current = draftEpoch;
624
+ if (!draftText)
625
+ return;
626
+ setText(draftText);
627
+ setCursor(draftText.length);
628
+ setSelectedIndex(0);
629
+ setPastedContentRefs([]);
630
+ setHistoryIndex(null);
631
+ historyDraftRef.current = "";
632
+ onDraftApplied?.();
633
+ }, [draftEpoch, draftText, onDraftApplied]);
543
634
  // After a terminal resize the previous-frame refs reference a layout that no
544
635
  // longer exists; carrying them forward makes `needsCursorRowCompensation`
545
636
  // compare new yoga heights against stale ones and offsets the cursor by a
@@ -608,7 +699,6 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
608
699
  // user can't type. Keeping the real cursor visible in the input makes it
609
700
  // flicker every time streaming output above it re-lays out the frame, so
610
701
  // we hide it entirely until input is active again.
611
- const [cursorTick, setCursorTick] = useState(0);
612
702
  useLayoutEffect(() => {
613
703
  let node = cursorLineRef.current ?? undefined;
614
704
  if (!node?.yogaNode) {
@@ -644,9 +734,13 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
644
734
  const sameRenderedFrame = previousOutputHeight === rootHeight &&
645
735
  previousViewportRowsRef.current === viewportRows &&
646
736
  previousInputFrameSignatureRef.current === inputFrameSignature;
647
- const rowCompensation = sameRenderedFrame
648
- ? previousRowCompensationRef.current
649
- : needsCursorRowCompensation(rootHeight, viewportRows, previousOutputHeight) ? 1 : 0;
737
+ const rowCompensation = resolveCursorRowCompensation({
738
+ sameRenderedFrame,
739
+ previousRowCompensation: previousRowCompensationRef.current,
740
+ nextOutputHeight: rootHeight,
741
+ viewportRows,
742
+ previousOutputHeight,
743
+ });
650
744
  previousOutputHeightRef.current = rootHeight;
651
745
  previousViewportRowsRef.current = viewportRows;
652
746
  previousInputFrameSignatureRef.current = inputFrameSignature;
@@ -59,7 +59,15 @@ function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, v
59
59
  const visibleParts = deferredParts.length > 0
60
60
  ? deferredParts
61
61
  : fallbackStreamingParts(deferredContent, tools);
62
- return (_jsxs(Box, { flexDirection: "column", children: [deferredReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: deferredReasoning }) })), visibleParts.length > 0 && (_jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
62
+ return (_jsxs(Box, { flexDirection: "column", children: [deferredReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: deferredReasoning }) })), visibleParts.length > 0 && (
63
+ // marginTop intentionally 0: this Box only mounts on the first non-empty
64
+ // streaming frame, so a marginTop=1 here would visibly insert a blank
65
+ // line under the user message right at that moment (the "spinner sits
66
+ // close, then content appears with a sudden gap, then spinner slides
67
+ // down" effect users perceive as flicker on the DOM xterm renderer).
68
+ // marginBottom=1 stays so streamed text doesn't collide with the
69
+ // WaitingIndicator rendered below.
70
+ _jsx(Box, { marginTop: 0, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
63
71
  }
64
72
  function MessageParts({ parts, terminalColumns, verboseTrace, pendingApproval, showExpandHint, nowTick, showActivity = false, streaming = false, }) {
65
73
  const lastToolsPartIndex = findLastToolsPartIndex(parts);
@@ -201,9 +209,10 @@ function ReasoningTraceBlock({ reasoning }) {
201
209
  }
202
210
  function CompactionSummaryBlock({ message }) {
203
211
  const theme = useTheme();
204
- const status = message.content.replace(/^✓\s*/, "").trim() || "Session compacted";
212
+ const rawStatus = message.content.replace(/^✓\s*/, "").trim();
213
+ const status = rawStatus.replace(/^Compaction complete\s*(?:·\s*)?/i, "").trim() || "Session compacted";
205
214
  const summary = message.compactionSummary?.trim();
206
- return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.success, bold: true, children: "\u2713 " }), _jsx(Text, { color: theme.accent, bold: true, children: "Compaction" }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", status] })] }), summary && (_jsx(Box, { marginTop: 1, paddingLeft: 3, flexDirection: "column", children: _jsx(MarkdownContent, { content: summary }) }))] }));
215
+ 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 }) })] }))] }));
207
216
  }
208
217
  function UserMessageBlock({ content, terminalColumns }) {
209
218
  const theme = useTheme();