@bubblebrain-ai/bubble 0.0.10 → 0.0.12

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 (175) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +6 -2
  3. package/dist/cli.d.ts +10 -0
  4. package/dist/cli.js +31 -3
  5. package/dist/feedback/collect.d.ts +7 -0
  6. package/dist/feedback/collect.js +119 -0
  7. package/dist/feedback/config.d.ts +14 -0
  8. package/dist/feedback/config.js +16 -0
  9. package/dist/feedback/redact.d.ts +1 -0
  10. package/dist/feedback/redact.js +25 -0
  11. package/dist/feedback/submit.d.ts +6 -0
  12. package/dist/feedback/submit.js +43 -0
  13. package/dist/feedback/types.d.ts +22 -0
  14. package/dist/feishu/agent-host/approval-card.d.ts +11 -0
  15. package/dist/feishu/agent-host/approval-card.js +46 -0
  16. package/dist/feishu/agent-host/approval-ui.d.ts +59 -0
  17. package/dist/feishu/agent-host/approval-ui.js +214 -0
  18. package/dist/feishu/agent-host/run-driver.d.ts +51 -0
  19. package/dist/feishu/agent-host/run-driver.js +302 -0
  20. package/dist/feishu/agent-host/runtime-deps.d.ts +33 -0
  21. package/dist/feishu/agent-host/runtime-deps.js +8 -0
  22. package/dist/feishu/card/budget.d.ts +40 -0
  23. package/dist/feishu/card/budget.js +134 -0
  24. package/dist/feishu/card/renderer.d.ts +29 -0
  25. package/dist/feishu/card/renderer.js +245 -0
  26. package/dist/feishu/card/run-state-types.d.ts +49 -0
  27. package/dist/feishu/card/run-state-types.js +15 -0
  28. package/dist/feishu/card/run-state.d.ts +21 -0
  29. package/dist/feishu/card/run-state.js +217 -0
  30. package/dist/feishu/channel/channel.d.ts +52 -0
  31. package/dist/feishu/channel/channel.js +74 -0
  32. package/dist/feishu/config.d.ts +24 -0
  33. package/dist/feishu/config.js +97 -0
  34. package/dist/feishu/format.d.ts +6 -0
  35. package/dist/feishu/format.js +14 -0
  36. package/dist/feishu/index.d.ts +4 -0
  37. package/dist/feishu/index.js +4 -0
  38. package/dist/feishu/logger.d.ts +31 -0
  39. package/dist/feishu/logger.js +62 -0
  40. package/dist/feishu/paths.d.ts +12 -0
  41. package/dist/feishu/paths.js +38 -0
  42. package/dist/feishu/process-registry.d.ts +29 -0
  43. package/dist/feishu/process-registry.js +90 -0
  44. package/dist/feishu/router/commands.d.ts +38 -0
  45. package/dist/feishu/router/commands.js +286 -0
  46. package/dist/feishu/router/event-router.d.ts +40 -0
  47. package/dist/feishu/router/event-router.js +208 -0
  48. package/dist/feishu/router/whitelist.d.ts +23 -0
  49. package/dist/feishu/router/whitelist.js +20 -0
  50. package/dist/feishu/runtime/active-runs.d.ts +32 -0
  51. package/dist/feishu/runtime/active-runs.js +84 -0
  52. package/dist/feishu/runtime/pending-queue.d.ts +36 -0
  53. package/dist/feishu/runtime/pending-queue.js +98 -0
  54. package/dist/feishu/runtime/process-pool.d.ts +29 -0
  55. package/dist/feishu/runtime/process-pool.js +49 -0
  56. package/dist/feishu/schema.d.ts +17 -0
  57. package/dist/feishu/schema.js +252 -0
  58. package/dist/feishu/scope/scope-registry.d.ts +39 -0
  59. package/dist/feishu/scope/scope-registry.js +148 -0
  60. package/dist/feishu/scope/session-binder.d.ts +44 -0
  61. package/dist/feishu/scope/session-binder.js +100 -0
  62. package/dist/feishu/scope/session-store.d.ts +24 -0
  63. package/dist/feishu/scope/session-store.js +73 -0
  64. package/dist/feishu/secrets.d.ts +37 -0
  65. package/dist/feishu/secrets.js +129 -0
  66. package/dist/feishu/serve.d.ts +12 -0
  67. package/dist/feishu/serve.js +288 -0
  68. package/dist/feishu/types.d.ts +75 -0
  69. package/dist/feishu/types.js +23 -0
  70. package/dist/feishu/wizard.d.ts +24 -0
  71. package/dist/feishu/wizard.js +121 -0
  72. package/dist/main.js +98 -32
  73. package/dist/model-catalog.js +3 -0
  74. package/dist/prompt/compose.js +3 -3
  75. package/dist/prompt/environment.js +2 -0
  76. package/dist/prompt/reminders.js +1 -1
  77. package/dist/provider-openai-codex.d.ts +8 -1
  78. package/dist/provider-openai-codex.js +33 -9
  79. package/dist/provider.d.ts +2 -0
  80. package/dist/session-title.d.ts +16 -0
  81. package/dist/session-title.js +134 -0
  82. package/dist/session-types.d.ts +5 -0
  83. package/dist/session.d.ts +16 -0
  84. package/dist/session.js +154 -2
  85. package/dist/skills/invocation.js +0 -18
  86. package/dist/skills/registry.d.ts +1 -0
  87. package/dist/skills/registry.js +2 -0
  88. package/dist/slash-commands/commands.js +15 -22
  89. package/dist/slash-commands/feishu.d.ts +17 -0
  90. package/dist/slash-commands/feishu.js +400 -0
  91. package/dist/slash-commands/registry.js +1 -1
  92. package/dist/slash-commands/types.d.ts +3 -1
  93. package/dist/text-display.d.ts +3 -0
  94. package/dist/text-display.js +25 -0
  95. package/dist/tools/index.d.ts +1 -0
  96. package/dist/tools/index.js +3 -1
  97. package/dist/tools/skill-search.d.ts +10 -0
  98. package/dist/tools/skill-search.js +134 -0
  99. package/dist/tools/skill.js +1 -4
  100. package/dist/tui-ink/app.js +265 -118
  101. package/dist/tui-ink/code-highlight.js +2 -3
  102. package/dist/tui-ink/detect-theme.d.ts +1 -18
  103. package/dist/tui-ink/detect-theme.js +1 -37
  104. package/dist/tui-ink/display-history.d.ts +20 -3
  105. package/dist/tui-ink/display-history.js +26 -27
  106. package/dist/tui-ink/feedback-dialog.d.ts +19 -0
  107. package/dist/tui-ink/feedback-dialog.js +123 -0
  108. package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
  109. package/dist/tui-ink/feishu-setup-picker.js +261 -0
  110. package/dist/tui-ink/input-box.d.ts +25 -1
  111. package/dist/tui-ink/input-box.js +132 -11
  112. package/dist/tui-ink/input-history.js +3 -5
  113. package/dist/tui-ink/markdown.d.ts +32 -0
  114. package/dist/tui-ink/markdown.js +111 -4
  115. package/dist/tui-ink/message-list.d.ts +1 -6
  116. package/dist/tui-ink/message-list.js +86 -34
  117. package/dist/tui-ink/model-picker.d.ts +18 -0
  118. package/dist/tui-ink/model-picker.js +81 -27
  119. package/dist/tui-ink/run-session-picker.d.ts +10 -0
  120. package/dist/tui-ink/run-session-picker.js +22 -0
  121. package/dist/tui-ink/run.js +7 -2
  122. package/dist/tui-ink/session-picker.d.ts +10 -0
  123. package/dist/tui-ink/session-picker.js +110 -0
  124. package/dist/tui-ink/terminal-mouse.d.ts +4 -0
  125. package/dist/tui-ink/terminal-mouse.js +23 -0
  126. package/dist/tui-ink/theme.js +2 -2
  127. package/dist/tui-ink/trace-groups.js +25 -2
  128. package/dist/tui-ink/welcome.js +2 -4
  129. package/package.json +4 -5
  130. package/dist/tui/clipboard.d.ts +0 -1
  131. package/dist/tui/clipboard.js +0 -53
  132. package/dist/tui/display-history.d.ts +0 -44
  133. package/dist/tui/display-history.js +0 -243
  134. package/dist/tui/escape-confirmation.d.ts +0 -15
  135. package/dist/tui/escape-confirmation.js +0 -30
  136. package/dist/tui/file-mentions.d.ts +0 -29
  137. package/dist/tui/file-mentions.js +0 -174
  138. package/dist/tui/global-key-router.d.ts +0 -3
  139. package/dist/tui/global-key-router.js +0 -87
  140. package/dist/tui/image-paste.d.ts +0 -95
  141. package/dist/tui/image-paste.js +0 -505
  142. package/dist/tui/markdown-inline.d.ts +0 -22
  143. package/dist/tui/markdown-inline.js +0 -68
  144. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  145. package/dist/tui/markdown-theme-rules.js +0 -164
  146. package/dist/tui/markdown-theme.d.ts +0 -5
  147. package/dist/tui/markdown-theme.js +0 -27
  148. package/dist/tui/opencode-spinner.d.ts +0 -21
  149. package/dist/tui/opencode-spinner.js +0 -216
  150. package/dist/tui/prompt-keybindings.d.ts +0 -42
  151. package/dist/tui/prompt-keybindings.js +0 -35
  152. package/dist/tui/recent-activity.d.ts +0 -8
  153. package/dist/tui/recent-activity.js +0 -71
  154. package/dist/tui/render-signature.d.ts +0 -1
  155. package/dist/tui/render-signature.js +0 -7
  156. package/dist/tui/run.d.ts +0 -38
  157. package/dist/tui/run.js +0 -6996
  158. package/dist/tui/sidebar-mcp.d.ts +0 -31
  159. package/dist/tui/sidebar-mcp.js +0 -62
  160. package/dist/tui/sidebar-state.d.ts +0 -12
  161. package/dist/tui/sidebar-state.js +0 -69
  162. package/dist/tui/streaming-tool-args.d.ts +0 -15
  163. package/dist/tui/streaming-tool-args.js +0 -30
  164. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  165. package/dist/tui/tool-renderers/fallback.js +0 -75
  166. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  167. package/dist/tui/tool-renderers/registry.js +0 -11
  168. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  169. package/dist/tui/tool-renderers/subagent.js +0 -114
  170. package/dist/tui/tool-renderers/types.d.ts +0 -36
  171. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  172. package/dist/tui/tool-renderers/write-preview.js +0 -30
  173. package/dist/tui/tool-renderers/write.d.ts +0 -6
  174. package/dist/tui/tool-renderers/write.js +0 -88
  175. /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
@@ -1,18 +1,39 @@
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;
34
+ export declare function isCtrlCInput(input: string, key: {
35
+ ctrl?: boolean;
36
+ }): boolean;
16
37
  export declare function shouldSubmitExactSlashSuggestion(input: string, suggestionName?: string): boolean;
17
38
  export declare function resolveSlashEnterAction(input: string, suggestions: Array<{
18
39
  name: string;
@@ -37,5 +58,8 @@ export declare function insertNewlineAtCursor(text: string, cursor: number): {
37
58
  text: string;
38
59
  cursor: number;
39
60
  };
40
- 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;
41
65
  export {};
@@ -8,11 +8,14 @@ import { useTheme } from "./theme.js";
8
8
  import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
9
9
  import { ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
10
10
  import { appendHistoryEntry, loadHistorySync, pushHistoryEntry, stepHistory, } from "./input-history.js";
11
+ import { stripTerminalMouseSequences } from "./terminal-mouse.js";
11
12
  const MIN_VISIBLE_LINES = 3;
12
13
  const MAX_VISIBLE_LINES = 6;
13
14
  const PADDING_X = 1;
14
15
  const PROMPT = " > ";
15
16
  const MAX_VISIBLE_SUGGESTIONS = 8;
17
+ const LONG_PASTE_CHAR_THRESHOLD = 1000;
18
+ const LONG_PASTE_LINE_THRESHOLD = 20;
16
19
  export function needsCursorRowCompensation(nextOutputHeight, viewportRows, previousOutputHeight) {
17
20
  const hadPreviousFrame = previousOutputHeight !== null && previousOutputHeight > 0;
18
21
  const isFullscreen = nextOutputHeight >= viewportRows;
@@ -26,6 +29,14 @@ export function needsCursorRowCompensation(nextOutputHeight, viewportRows, previ
26
29
  // line below the output, so pass y+1 in those cases.
27
30
  return isFullscreen || wasOverflowing || (isOverflowing && hadPreviousFrame) || isLeavingFullscreen;
28
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
+ }
37
+ export function isCtrlCInput(input, key) {
38
+ return input === "\x03" || (key.ctrl === true && input.toLowerCase() === "c");
39
+ }
29
40
  // Break a logical line into segments that each fit within `maxWidth` display
30
41
  // columns. Uses string-width so CJK and emoji wrap correctly; empty lines
31
42
  // still produce one empty segment so cursors on blank lines render.
@@ -137,7 +148,42 @@ export function insertNewlineAtCursor(text, cursor) {
137
148
  cursor: clampedCursor + 1,
138
149
  };
139
150
  }
140
- 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, }) {
141
187
  const theme = useTheme();
142
188
  const width = terminalColumns;
143
189
  const [text, setText] = useState("");
@@ -145,6 +191,7 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
145
191
  const [selectedIndex, setSelectedIndex] = useState(0);
146
192
  const [projectFiles, setProjectFiles] = useState(null);
147
193
  const [attachments, setAttachments] = useState([]);
194
+ const [pastedContentRefs, setPastedContentRefs] = useState([]);
148
195
  const [history, setHistory] = useState(() => loadHistorySync());
149
196
  const [historyIndex, setHistoryIndex] = useState(null);
150
197
  const historyDraftRef = useRef("");
@@ -279,7 +326,14 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
279
326
  const imageTokens = tokens.filter(isImageFilePath);
280
327
  if (imageTokens.length === 0) {
281
328
  // Plain text paste — insert into the input at the cursor.
282
- 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
+ }
283
337
  clearPending();
284
338
  return;
285
339
  }
@@ -333,20 +387,28 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
333
387
  setSelectedIndex(0);
334
388
  };
335
389
  const submitInput = (submittedText) => {
336
- if (submittedText.trim().length === 0 && attachments.length === 0)
390
+ const expandedText = expandPastedContentMarkers(submittedText, pastedContentRefs);
391
+ if (expandedText.trim().length === 0 && attachments.length === 0)
337
392
  return;
338
- onSubmit({ text: submittedText, images: attachments });
339
- if (submittedText.trim().length > 0) {
340
- 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);
341
402
  if (nextHistory !== history) {
342
403
  setHistory(nextHistory);
343
- appendHistoryEntry(submittedText);
404
+ appendHistoryEntry(expandedText);
344
405
  }
345
406
  }
346
407
  setText("");
347
408
  setCursor(0);
348
409
  setSelectedIndex(0);
349
410
  setAttachments([]);
411
+ setPastedContentRefs([]);
350
412
  setHistoryIndex(null);
351
413
  historyDraftRef.current = "";
352
414
  };
@@ -365,8 +427,15 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
365
427
  return false;
366
428
  };
367
429
  useInput((input, key) => {
430
+ const strippedInput = stripTerminalMouseSequences(input);
431
+ if (strippedInput !== input && !strippedInput) {
432
+ return;
433
+ }
434
+ input = strippedInput;
368
435
  if (disabled)
369
436
  return;
437
+ if (isCtrlCInput(input, key))
438
+ return;
370
439
  if (process.env.BUBBLE_KEY_DEBUG) {
371
440
  try {
372
441
  appendFileSync("/tmp/bubble-key.log", JSON.stringify({
@@ -490,6 +559,7 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
490
559
  setHistoryIndex(result.index);
491
560
  historyDraftRef.current = result.draft;
492
561
  setSelectedIndex(0);
562
+ setPastedContentRefs([]);
493
563
  }
494
564
  return;
495
565
  }
@@ -505,6 +575,7 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
505
575
  setHistoryIndex(result.index);
506
576
  historyDraftRef.current = result.draft;
507
577
  setSelectedIndex(0);
578
+ setPastedContentRefs([]);
508
579
  }
509
580
  return;
510
581
  }
@@ -526,8 +597,55 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
526
597
  const previousViewportRowsRef = useRef(null);
527
598
  const previousInputFrameSignatureRef = useRef(null);
528
599
  const previousRowCompensationRef = useRef(0);
600
+ const lastCursorResetEpochRef = useRef(null);
601
+ const lastDraftEpochRef = useRef(null);
602
+ const lastWidthRef = useRef(null);
529
603
  const { setCursorPosition } = useCursor();
530
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]);
634
+ // After a terminal resize the previous-frame refs reference a layout that no
635
+ // longer exists; carrying them forward makes `needsCursorRowCompensation`
636
+ // compare new yoga heights against stale ones and offsets the cursor by a
637
+ // row. Reset to a "no previous frame" state so the next layout effect treats
638
+ // the new width as a fresh start.
639
+ useLayoutEffect(() => {
640
+ if (lastWidthRef.current !== null && lastWidthRef.current !== width) {
641
+ previousOutputHeightRef.current = null;
642
+ previousViewportRowsRef.current = null;
643
+ previousInputFrameSignatureRef.current = null;
644
+ previousRowCompensationRef.current = 0;
645
+ lastCursorRef.current = null;
646
+ }
647
+ lastWidthRef.current = width;
648
+ }, [width]);
531
649
  const contentWidth = Math.max(1, width - PADDING_X * 2);
532
650
  const lineWidth = Math.max(1, contentWidth - PROMPT.length);
533
651
  const visualLines = useMemo(() => computeVisualLines(text, lineWidth), [text, lineWidth]);
@@ -581,7 +699,6 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
581
699
  // user can't type. Keeping the real cursor visible in the input makes it
582
700
  // flicker every time streaming output above it re-lays out the frame, so
583
701
  // we hide it entirely until input is active again.
584
- const [cursorTick, setCursorTick] = useState(0);
585
702
  useLayoutEffect(() => {
586
703
  let node = cursorLineRef.current ?? undefined;
587
704
  if (!node?.yogaNode) {
@@ -617,9 +734,13 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
617
734
  const sameRenderedFrame = previousOutputHeight === rootHeight &&
618
735
  previousViewportRowsRef.current === viewportRows &&
619
736
  previousInputFrameSignatureRef.current === inputFrameSignature;
620
- const rowCompensation = sameRenderedFrame
621
- ? previousRowCompensationRef.current
622
- : needsCursorRowCompensation(rootHeight, viewportRows, previousOutputHeight) ? 1 : 0;
737
+ const rowCompensation = resolveCursorRowCompensation({
738
+ sameRenderedFrame,
739
+ previousRowCompensation: previousRowCompensationRef.current,
740
+ nextOutputHeight: rootHeight,
741
+ viewportRows,
742
+ previousOutputHeight,
743
+ });
623
744
  previousOutputHeightRef.current = rootHeight;
624
745
  previousViewportRowsRef.current = viewportRows;
625
746
  previousInputFrameSignatureRef.current = inputFrameSignature;
@@ -22,7 +22,7 @@ export function loadHistorySync(filePath = defaultHistoryFilePath()) {
22
22
  out.push(parsed);
23
23
  }
24
24
  catch {
25
- // Malformed line skip rather than fail the whole load.
25
+ // Malformed line - skip rather than fail the whole load.
26
26
  }
27
27
  }
28
28
  return out.length > MAX_HISTORY_ENTRIES ? out.slice(-MAX_HISTORY_ENTRIES) : out;
@@ -42,9 +42,9 @@ export function appendHistoryEntry(entry, filePath = defaultHistoryFilePath()) {
42
42
  // Persistence is best-effort; never crash the composer over disk IO.
43
43
  }
44
44
  }
45
- // Pure transition for ↑/↓ navigation. `index === null` means the user is
45
+ // Pure transition for up/down navigation. `index === null` means the user is
46
46
  // editing a fresh draft; otherwise it points at history[index]. When stepping
47
- // from the draft into history we snapshot the current text so past the
47
+ // from the draft into history we snapshot the current text so down past the
48
48
  // newest entry can restore it.
49
49
  export function stepHistory(state, direction, currentText) {
50
50
  const { history, index, draft } = state;
@@ -61,13 +61,11 @@ export function stepHistory(state, direction, currentText) {
61
61
  }
62
62
  return noChange;
63
63
  }
64
- // down
65
64
  if (index === null)
66
65
  return noChange;
67
66
  if (index < history.length - 1) {
68
67
  return { text: history[index + 1], index: index + 1, draft, changed: true };
69
68
  }
70
- // Past the newest entry: restore the saved draft and clear it.
71
69
  return { text: draft, index: null, draft: "", changed: true };
72
70
  }
73
71
  // Push to in-memory history with last-entry dedupe so repeated identical
@@ -30,7 +30,39 @@ interface InlineStyle {
30
30
  code?: boolean;
31
31
  }
32
32
  export declare function parseMarkdownBlocks(text: string): MarkdownBlock[];
33
+ /**
34
+ * Return the byte offset where the LAST markdown block begins in `text`.
35
+ *
36
+ * Used by the streaming renderer to split incoming content into a "stable
37
+ * prefix" (everything before the in-flight block — already-closed blocks)
38
+ * and an "unstable suffix" (the block currently being typed by the model).
39
+ * Mirrors parseMarkdownBlocks's lexing rules so the boundary it produces is
40
+ * compatible with how MarkdownContent will later parse the prefix.
41
+ *
42
+ * Returns `text.length` when no blocks are present (empty / whitespace-only
43
+ * input), and `0` when the entire text is a single in-flight block.
44
+ */
45
+ export declare function findLastBlockStart(text: string): number;
33
46
  export declare function parseMarkdownInlineSegments(text: string, style?: InlineStyle): MarkdownInlineSegment[];
47
+ /**
48
+ * Streaming-aware wrapper around `MarkdownContent`.
49
+ *
50
+ * On every render, splits the incoming `content` into a stable prefix
51
+ * (everything before the in-flight block) and an unstable suffix (the block
52
+ * currently being typed). The two halves are rendered as two separate
53
+ * `MarkdownContent` instances; the stable one uses the same `content` prop
54
+ * across deltas, so its internal `useMemo([content])` short-circuits and
55
+ * does NOT re-parse on each token — which is the whole point. Only the
56
+ * shorter unstable suffix re-parses per delta.
57
+ *
58
+ * The boundary advances monotonically (the prefix only grows). A defensive
59
+ * reset handles the rare case where `content` is replaced wholesale (e.g.,
60
+ * the user re-enters a turn).
61
+ */
62
+ export declare function StreamingMarkdown({ content, maxWidth, }: {
63
+ content: string;
64
+ maxWidth?: number;
65
+ }): import("react/jsx-runtime").JSX.Element;
34
66
  export declare function MarkdownContent({ content, maxWidth, }: {
35
67
  content: string;
36
68
  maxWidth?: number;
@@ -89,6 +89,85 @@ export function parseMarkdownBlocks(text) {
89
89
  }
90
90
  return blocks;
91
91
  }
92
+ /**
93
+ * Return the byte offset where the LAST markdown block begins in `text`.
94
+ *
95
+ * Used by the streaming renderer to split incoming content into a "stable
96
+ * prefix" (everything before the in-flight block — already-closed blocks)
97
+ * and an "unstable suffix" (the block currently being typed by the model).
98
+ * Mirrors parseMarkdownBlocks's lexing rules so the boundary it produces is
99
+ * compatible with how MarkdownContent will later parse the prefix.
100
+ *
101
+ * Returns `text.length` when no blocks are present (empty / whitespace-only
102
+ * input), and `0` when the entire text is a single in-flight block.
103
+ */
104
+ export function findLastBlockStart(text) {
105
+ if (text.length === 0)
106
+ return 0;
107
+ const lines = text.split("\n");
108
+ // `split("\n")` on "abc\n" yields ["abc", ""]; on "abc" yields ["abc"]. The
109
+ // trailing newline only contributes a length-1 separator for every line
110
+ // except the final element produced by split.
111
+ const lineLengthWithSeparator = (idx) => {
112
+ const len = lines[idx].length;
113
+ return idx < lines.length - 1 ? len + 1 : len;
114
+ };
115
+ let i = 0;
116
+ let offset = 0;
117
+ let lastBlockStart = text.length;
118
+ while (i < lines.length) {
119
+ const line = lines[i];
120
+ // Code block (fenced). Unclosed fences extend through EOF — that's exactly
121
+ // what we want for streaming, since marked-lexer-style "single token until
122
+ // close" keeps the in-flight code in the unstable suffix.
123
+ if (line.startsWith("```")) {
124
+ lastBlockStart = offset;
125
+ offset += lineLengthWithSeparator(i);
126
+ i++;
127
+ while (i < lines.length && !lines[i].startsWith("```")) {
128
+ offset += lineLengthWithSeparator(i);
129
+ i++;
130
+ }
131
+ if (i < lines.length) {
132
+ offset += lineLengthWithSeparator(i);
133
+ i++;
134
+ }
135
+ continue;
136
+ }
137
+ // Table — same shape as parseMarkdownBlocks: consume consecutive `|` lines.
138
+ if (line.trim().startsWith("|")) {
139
+ lastBlockStart = offset;
140
+ while (i < lines.length && lines[i].trim().startsWith("|")) {
141
+ offset += lineLengthWithSeparator(i);
142
+ i++;
143
+ }
144
+ continue;
145
+ }
146
+ // Heading.
147
+ if (/^#{1,6}\s+/.test(line)) {
148
+ lastBlockStart = offset;
149
+ offset += lineLengthWithSeparator(i);
150
+ i++;
151
+ continue;
152
+ }
153
+ // Blank line — does not start a block; just advances the offset.
154
+ if (line.trim() === "") {
155
+ offset += lineLengthWithSeparator(i);
156
+ i++;
157
+ continue;
158
+ }
159
+ // Paragraph — runs until blank line or start of another block.
160
+ lastBlockStart = offset;
161
+ while (i < lines.length &&
162
+ lines[i].trim() !== "" &&
163
+ !lines[i].startsWith("```") &&
164
+ !lines[i].trim().startsWith("|")) {
165
+ offset += lineLengthWithSeparator(i);
166
+ i++;
167
+ }
168
+ }
169
+ return lastBlockStart;
170
+ }
92
171
  function parseTableRow(line) {
93
172
  let body = line.trim();
94
173
  if (body.startsWith("|"))
@@ -263,10 +342,8 @@ function InlineText({ text }) {
263
342
  function CodeBlock({ lang, lines }) {
264
343
  const theme = useTheme();
265
344
  // Lazy init: try sync highlight when shiki is already warm so the very first
266
- // paint carries highlighted output. This matters because MessageList renders
267
- // committed messages inside Ink's <Static>, which only paints each item once
268
- // — anything we ship via setState in useEffect lands too late to appear in
269
- // scrollback. Fall back to raw lines if shiki hasn't loaded yet.
345
+ // paint carries highlighted output. Fall back to raw lines if shiki hasn't
346
+ // loaded yet; useEffect will refresh the mounted transcript item later.
270
347
  const [highlighted, setHighlighted] = React.useState(() => {
271
348
  const code = lines.join("\n");
272
349
  if (!code)
@@ -377,6 +454,36 @@ function HeadingBlock({ level, text }) {
377
454
  }
378
455
  return (_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { ...props, children: text }) }));
379
456
  }
457
+ /**
458
+ * Streaming-aware wrapper around `MarkdownContent`.
459
+ *
460
+ * On every render, splits the incoming `content` into a stable prefix
461
+ * (everything before the in-flight block) and an unstable suffix (the block
462
+ * currently being typed). The two halves are rendered as two separate
463
+ * `MarkdownContent` instances; the stable one uses the same `content` prop
464
+ * across deltas, so its internal `useMemo([content])` short-circuits and
465
+ * does NOT re-parse on each token — which is the whole point. Only the
466
+ * shorter unstable suffix re-parses per delta.
467
+ *
468
+ * The boundary advances monotonically (the prefix only grows). A defensive
469
+ * reset handles the rare case where `content` is replaced wholesale (e.g.,
470
+ * the user re-enters a turn).
471
+ */
472
+ export function StreamingMarkdown({ content, maxWidth, }) {
473
+ const stablePrefixRef = React.useRef("");
474
+ if (!content.startsWith(stablePrefixRef.current)) {
475
+ stablePrefixRef.current = "";
476
+ }
477
+ const boundary = stablePrefixRef.current.length;
478
+ const tail = content.substring(boundary);
479
+ const advance = findLastBlockStart(tail);
480
+ if (advance > 0) {
481
+ stablePrefixRef.current = content.substring(0, boundary + advance);
482
+ }
483
+ const stablePrefix = stablePrefixRef.current;
484
+ const unstableSuffix = content.substring(stablePrefix.length);
485
+ return (_jsxs(Box, { flexDirection: "column", children: [stablePrefix && _jsx(MarkdownContent, { content: stablePrefix, maxWidth: maxWidth }), unstableSuffix && _jsx(MarkdownContent, { content: unstableSuffix, maxWidth: maxWidth })] }));
486
+ }
380
487
  export function MarkdownContent({ content, maxWidth, }) {
381
488
  const blocks = React.useMemo(() => parseMarkdownBlocks(content), [content]);
382
489
  return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => {
@@ -21,12 +21,7 @@ interface MessageListProps {
21
21
  pendingApproval?: PendingApprovalHint | null;
22
22
  /** Animation tick used to refresh in-progress elapsed counters. */
23
23
  nowTick?: number;
24
- /**
25
- * Optional banner rendered as the first item of the scrollback Static
26
- * stream. Committed to scrollback once on initial mount so it doesn't
27
- * float between older messages and the live tail as the conversation
28
- * progresses.
29
- */
24
+ /** Optional banner rendered as the first item in the app-controlled transcript. */
30
25
  welcomeBanner?: React.ReactNode;
31
26
  }
32
27
  export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, verboseTrace, pendingApproval, nowTick, welcomeBanner, }: MessageListProps): import("react/jsx-runtime").JSX.Element;