@bubblebrain-ai/bubble 0.0.35 → 0.0.37

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 (144) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/categories.d.ts +66 -2
  3. package/dist/agent/categories.js +120 -19
  4. package/dist/agent/discovery-barrier.js +1 -1
  5. package/dist/agent/profiles.d.ts +1 -0
  6. package/dist/agent/profiles.js +25 -11
  7. package/dist/agent/routing-catalog.d.ts +113 -0
  8. package/dist/agent/routing-catalog.js +240 -0
  9. package/dist/agent/workflow/control.d.ts +14 -0
  10. package/dist/agent/workflow/control.js +30 -9
  11. package/dist/agent.d.ts +75 -5
  12. package/dist/agent.js +313 -61
  13. package/dist/approval/controller.js +28 -0
  14. package/dist/approval/tool-helper.js +2 -0
  15. package/dist/approval/types.d.ts +21 -1
  16. package/dist/cli.d.ts +1 -1
  17. package/dist/cli.js +2 -1
  18. package/dist/config.d.ts +4 -0
  19. package/dist/config.js +4 -0
  20. package/dist/context/compact-llm.d.ts +2 -2
  21. package/dist/external-runtime/grok-acp.d.ts +56 -0
  22. package/dist/external-runtime/grok-acp.js +386 -0
  23. package/dist/external-runtime/grok-binary.d.ts +25 -0
  24. package/dist/external-runtime/grok-binary.js +108 -0
  25. package/dist/external-runtime/grok-errors.d.ts +7 -0
  26. package/dist/external-runtime/grok-errors.js +15 -0
  27. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  28. package/dist/external-runtime/grok-input-policy.js +52 -0
  29. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  30. package/dist/external-runtime/grok-ndjson.js +193 -0
  31. package/dist/external-runtime/grok-network.d.ts +15 -0
  32. package/dist/external-runtime/grok-network.js +81 -0
  33. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  34. package/dist/external-runtime/grok-preflight.js +194 -0
  35. package/dist/external-runtime/grok-process.d.ts +26 -0
  36. package/dist/external-runtime/grok-process.js +184 -0
  37. package/dist/external-runtime/grok-profile.d.ts +32 -0
  38. package/dist/external-runtime/grok-profile.js +419 -0
  39. package/dist/external-runtime/grok-provider.d.ts +31 -0
  40. package/dist/external-runtime/grok-provider.js +41 -0
  41. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  42. package/dist/external-runtime/grok-runtime.js +882 -0
  43. package/dist/external-runtime/index.d.ts +13 -0
  44. package/dist/external-runtime/index.js +13 -0
  45. package/dist/external-runtime/session-policy.d.ts +11 -0
  46. package/dist/external-runtime/session-policy.js +30 -0
  47. package/dist/external-runtime/types.d.ts +65 -0
  48. package/dist/external-runtime/types.js +1 -0
  49. package/dist/feishu/agent-host/approval-card.js +21 -0
  50. package/dist/feishu/agent-host/run-driver.js +19 -3
  51. package/dist/feishu/serve.js +1 -0
  52. package/dist/goal/tools.d.ts +6 -3
  53. package/dist/goal/tools.js +7 -19
  54. package/dist/main.js +105 -27
  55. package/dist/memory/db.d.ts +2 -0
  56. package/dist/memory/db.js +10 -0
  57. package/dist/memory/index.d.ts +1 -0
  58. package/dist/memory/index.js +1 -0
  59. package/dist/memory/phase1.js +14 -2
  60. package/dist/memory/phase2.js +21 -2
  61. package/dist/memory/prompts.js +1 -1
  62. package/dist/memory/session-policy.d.ts +25 -0
  63. package/dist/memory/session-policy.js +55 -0
  64. package/dist/memory/storage.d.ts +1 -0
  65. package/dist/memory/storage.js +13 -0
  66. package/dist/model-catalog.d.ts +18 -0
  67. package/dist/model-catalog.js +87 -41
  68. package/dist/model-config.d.ts +3 -1
  69. package/dist/model-config.js +11 -0
  70. package/dist/oauth/grok.d.ts +25 -0
  71. package/dist/oauth/grok.js +218 -0
  72. package/dist/oauth/index.d.ts +1 -0
  73. package/dist/oauth/index.js +1 -0
  74. package/dist/oauth/storage.d.ts +9 -0
  75. package/dist/oauth/storage.js +25 -0
  76. package/dist/orchestrator/default-hooks.js +17 -7
  77. package/dist/prompt/compose.d.ts +3 -0
  78. package/dist/prompt/compose.js +3 -0
  79. package/dist/prompt/delegation.js +22 -3
  80. package/dist/prompt/environment.js +0 -4
  81. package/dist/prompt/reminders.d.ts +0 -1
  82. package/dist/prompt/reminders.js +1 -11
  83. package/dist/prompt/routing.d.ts +14 -0
  84. package/dist/prompt/routing.js +71 -0
  85. package/dist/prompt/runtime.js +1 -1
  86. package/dist/prompt/task-reminders.d.ts +19 -0
  87. package/dist/prompt/task-reminders.js +49 -0
  88. package/dist/provider-ai-sdk.js +2 -2
  89. package/dist/provider-grok.d.ts +26 -0
  90. package/dist/provider-grok.js +59 -0
  91. package/dist/provider-openai-codex.d.ts +17 -1
  92. package/dist/provider-openai-codex.js +101 -29
  93. package/dist/provider-registry.d.ts +72 -0
  94. package/dist/provider-registry.js +336 -59
  95. package/dist/provider-transform.d.ts +1 -1
  96. package/dist/provider-transform.js +28 -5
  97. package/dist/provider.d.ts +3 -0
  98. package/dist/provider.js +14 -0
  99. package/dist/session-types.d.ts +19 -1
  100. package/dist/session.d.ts +1 -0
  101. package/dist/session.js +4 -0
  102. package/dist/slash-commands/commands.js +224 -14
  103. package/dist/slash-commands/types.d.ts +14 -1
  104. package/dist/system-prompt.d.ts +2 -0
  105. package/dist/tools/agent-lifecycle.js +9 -11
  106. package/dist/tools/bash.js +1 -1
  107. package/dist/tools/child-tools.js +2 -0
  108. package/dist/tools/index.d.ts +2 -2
  109. package/dist/tools/index.js +7 -5
  110. package/dist/tools/lsp.js +1 -0
  111. package/dist/tools/memory.d.ts +7 -2
  112. package/dist/tools/memory.js +28 -43
  113. package/dist/tools/server.js +4 -0
  114. package/dist/tools/tool-search.d.ts +1 -1
  115. package/dist/tools/tool-search.js +23 -3
  116. package/dist/tui/model-switch.d.ts +7 -0
  117. package/dist/tui/model-switch.js +15 -5
  118. package/dist/tui-ink/app.d.ts +3 -1
  119. package/dist/tui-ink/app.js +649 -233
  120. package/dist/tui-ink/approval/approval-dialog.js +36 -0
  121. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  122. package/dist/tui-ink/composer-buffer.js +317 -0
  123. package/dist/tui-ink/footer.d.ts +1 -0
  124. package/dist/tui-ink/footer.js +7 -2
  125. package/dist/tui-ink/input-box.d.ts +35 -1
  126. package/dist/tui-ink/input-box.js +215 -122
  127. package/dist/tui-ink/message-list.js +5 -5
  128. package/dist/tui-ink/model-picker.d.ts +61 -4
  129. package/dist/tui-ink/model-picker.js +486 -133
  130. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  131. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  132. package/dist/tui-ink/run.d.ts +3 -0
  133. package/dist/tui-ink/run.js +1 -1
  134. package/dist/tui-ink/subagent-view.d.ts +31 -0
  135. package/dist/tui-ink/subagent-view.js +82 -1
  136. package/dist/types.d.ts +5 -5
  137. package/dist/types.js +1 -1
  138. package/dist/variant/thinking-level.d.ts +2 -2
  139. package/dist/variant/thinking-level.js +2 -1
  140. package/dist/variant/variant-resolver.d.ts +9 -0
  141. package/dist/variant/variant-resolver.js +17 -0
  142. package/package.json +2 -1
  143. package/dist/tools/task.d.ts +0 -2
  144. package/dist/tools/task.js +0 -58
@@ -6,14 +6,76 @@ import { appendFileSync } from "node:fs";
6
6
  import { registry as slashRegistry } from "../slash-commands/index.js";
7
7
  import { useTheme } from "./theme.js";
8
8
  import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
9
- import { bareImageFilenameFromPaste, ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
9
+ import { bareImageFilenameFromPaste, extractImagePathTokens, ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
10
10
  import { appendHistoryEntry, loadHistoryEntriesSync, pushHistoryEntry, stepHistory, } from "./input-history.js";
11
+ import { cloneComposerBuffer, createComposerBuffer, deleteComposerBackward, deleteComposerForward, expandComposerBuffer, hasActiveComposerPastes, insertComposerPaste, insertComposerText, moveComposerCursor, replaceComposerRange, } from "./composer-buffer.js";
11
12
  import { isKeyReleaseEvent } from "./key-events.js";
13
+ import { PasteOperationTracker } from "./paste-operation-tracker.js";
12
14
  import { stripTerminalMouseSequences } from "./terminal-mouse.js";
13
15
  import { submitPayloadFingerprint } from "./submit-dedupe.js";
14
16
  export { createPastedContentMarker, expandPastedContentMarkers, shouldCollapsePastedContent, } from "../tui/paste-placeholder.js";
15
- import { createPastedContentMarker, expandPastedContentMarkers, shouldCollapsePastedContent, } from "../tui/paste-placeholder.js";
17
+ import { shouldCollapsePastedContent, } from "../tui/paste-placeholder.js";
16
18
  import { imageDisplayLabel, stripInlineImageLabels } from "../tui/image-display.js";
19
+ export function resolveComposerAtContext(text, cursor, isSlashContext, allowWorkspaceMentions = true) {
20
+ return !allowWorkspaceMentions || isSlashContext ? null : findAtContext(text, cursor);
21
+ }
22
+ export function createComposerDraftSnapshot(input) {
23
+ return {
24
+ buffer: cloneComposerBuffer(input.buffer),
25
+ attachments: input.attachments.map((attachment) => ({ ...attachment })),
26
+ imageLabelStartOverride: input.imageLabelStartOverride,
27
+ };
28
+ }
29
+ /**
30
+ * Browse persisted history without turning marker-shaped text into live paste
31
+ * tokens, while preserving the exact unsent draft in memory for the final
32
+ * Down transition back out of history.
33
+ */
34
+ export function stepComposerHistory(state, history, direction) {
35
+ const plainDraft = state.draftSnapshot
36
+ ? {
37
+ text: state.draftSnapshot.buffer.text,
38
+ images: state.draftSnapshot.attachments,
39
+ ...(state.draftSnapshot.imageLabelStartOverride !== null
40
+ ? { imageDisplayStart: state.draftSnapshot.imageLabelStartOverride }
41
+ : {}),
42
+ }
43
+ : { text: "", images: [] };
44
+ const result = stepHistory({ history, index: state.historyIndex, draft: plainDraft }, direction, {
45
+ text: state.buffer.text,
46
+ images: state.attachments,
47
+ ...(state.imageLabelStartOverride !== null
48
+ ? { imageDisplayStart: state.imageLabelStartOverride }
49
+ : {}),
50
+ });
51
+ if (!result.changed)
52
+ return { ...state, changed: false };
53
+ let draftSnapshot = state.draftSnapshot;
54
+ if (state.historyIndex === null && result.index !== null && draftSnapshot === null) {
55
+ draftSnapshot = createComposerDraftSnapshot({
56
+ buffer: state.buffer,
57
+ attachments: state.attachments,
58
+ imageLabelStartOverride: state.imageLabelStartOverride,
59
+ });
60
+ }
61
+ if (result.index === null && draftSnapshot) {
62
+ const restored = createComposerDraftSnapshot(draftSnapshot);
63
+ return {
64
+ ...restored,
65
+ historyIndex: null,
66
+ draftSnapshot: null,
67
+ changed: true,
68
+ };
69
+ }
70
+ return {
71
+ buffer: createComposerBuffer(result.text),
72
+ attachments: (result.images ?? []).map((attachment) => ({ ...attachment })),
73
+ imageLabelStartOverride: result.imageDisplayStart ?? null,
74
+ historyIndex: result.index,
75
+ draftSnapshot: result.index === null ? null : draftSnapshot,
76
+ changed: true,
77
+ };
78
+ }
17
79
  const MAX_VISIBLE_LINES = 6;
18
80
  const PADDING_X = 1;
19
81
  const PROMPT = " > ";
@@ -236,6 +298,9 @@ export function resolveInkEnterIntent(input, key) {
236
298
  return "newline";
237
299
  return "submit";
238
300
  }
301
+ export function shouldDeferComposerCommitWhilePastePending(hasPendingPaste, enterIntent, key) {
302
+ return hasPendingPaste && (enterIntent === "submit" || key.tab === true);
303
+ }
239
304
  export function insertNewlineAtCursor(text, cursor) {
240
305
  const clampedCursor = Math.max(0, Math.min(text.length, cursor));
241
306
  return {
@@ -320,49 +385,69 @@ export function resolveComposerEditAction(input, key) {
320
385
  return "delete-line-end";
321
386
  return null;
322
387
  }
323
- export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDownAtBottom, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, localSlashCommands = [], terminalColumns, cwd, sessionFile, nextImageLabelStart = 1, }) {
388
+ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDownAtBottom, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, localSlashCommands = [], allowedSlashCommands, allowWorkspaceMentions = true, allowImageAttachments = true, terminalColumns, cwd, sessionFile, nextImageLabelStart = 1, }) {
324
389
  const theme = useTheme();
325
390
  const width = terminalColumns;
326
391
  const historyScope = useMemo(() => ({ sessionFile, cwd }), [sessionFile, cwd]);
327
392
  const hardwareCursorEnabled = shouldUseHardwareComposerCursor();
328
- const [text, setText] = useState("");
329
- const [cursor, setCursor] = useState(0);
393
+ const [composerBuffer, setComposerBuffer] = useState(() => createComposerBuffer());
394
+ const composerBufferRef = useRef(composerBuffer);
395
+ composerBufferRef.current = composerBuffer;
396
+ const updateComposerBuffer = React.useCallback((transition) => {
397
+ const next = transition(composerBufferRef.current);
398
+ composerBufferRef.current = next;
399
+ setComposerBuffer(next);
400
+ return next;
401
+ }, []);
402
+ const { text, cursor } = composerBuffer;
330
403
  const [softwareCursorVisible, setSoftwareCursorVisible] = useState(true);
331
404
  const [selectedIndex, setSelectedIndex] = useState(0);
332
405
  const [projectFiles, setProjectFiles] = useState(null);
333
406
  const [attachments, setAttachments] = useState([]);
334
407
  const [imageLabelStartOverride, setImageLabelStartOverride] = useState(null);
335
- const [pastedContentRefs, setPastedContentRefs] = useState([]);
336
408
  const [history, setHistory] = useState(() => loadHistoryEntriesSync({ scope: historyScope }));
337
409
  const [historyIndex, setHistoryIndex] = useState(null);
338
- const historyDraftRef = useRef("");
410
+ const historyDraftRef = useRef(null);
339
411
  const historyScopeRef = useRef(historyScope);
412
+ const historyScopeKey = `${historyScope.cwd}\0${historyScope.sessionFile ?? ""}`;
413
+ const previousHistoryScopeKeyRef = useRef(historyScopeKey);
414
+ const draftIdentityEpochRef = useRef(0);
415
+ const pasteOperationsRef = useRef(new PasteOperationTracker());
340
416
  const submittedPayloadFingerprintRef = useRef(null);
341
417
  const loadingFilesRef = useRef(false);
342
- const nextPastedContentIndexRef = useRef(1);
343
418
  // Kept equal to attachments.length so a synchronous multi-image paste loop
344
419
  // assigns each image its correct (distinct) inline label index.
345
420
  const attachmentCountRef = useRef(0);
346
- // Paste and the keystrokes that follow can arrive inside the same stdin chunk
347
- // and dispatch within one discreteUpdates batch. If the Enter that a user
348
- // typed after a paste fires before React commits the paste-driven setState,
349
- // useInput's Enter branch reads stale `text` and submits without the paste.
350
- // This ref flips synchronously at paste-start and clears after the paste
351
- // commit has been flushed — useInput's Enter handler bails while it's set.
352
- const pastePendingRef = useRef(false);
353
421
  historyScopeRef.current = historyScope;
422
+ const invalidateDraftAsyncWork = React.useCallback(() => {
423
+ draftIdentityEpochRef.current += 1;
424
+ pasteOperationsRef.current.invalidateAll();
425
+ }, []);
354
426
  const ensureImageLabelStart = React.useCallback(() => {
355
427
  setImageLabelStartOverride((current) => current ?? nextImageLabelStart);
356
428
  }, [nextImageLabelStart]);
357
429
  useEffect(() => {
430
+ const scopeChanged = previousHistoryScopeKeyRef.current !== historyScopeKey;
431
+ previousHistoryScopeKeyRef.current = historyScopeKey;
358
432
  setHistory(loadHistoryEntriesSync({ scope: historyScope }));
359
433
  setHistoryIndex(null);
360
434
  setImageLabelStartOverride(null);
361
- historyDraftRef.current = "";
362
- }, [historyScope]);
435
+ historyDraftRef.current = null;
436
+ if (scopeChanged) {
437
+ updateComposerBuffer(() => createComposerBuffer());
438
+ setAttachments([]);
439
+ attachmentCountRef.current = 0;
440
+ setProjectFiles(null);
441
+ loadingFilesRef.current = false;
442
+ }
443
+ invalidateDraftAsyncWork();
444
+ }, [historyScope, historyScopeKey, invalidateDraftAsyncWork, updateComposerBuffer]);
445
+ useEffect(() => () => {
446
+ invalidateDraftAsyncWork();
447
+ }, [invalidateDraftAsyncWork]);
363
448
  const isSlashContext = text.startsWith("/") && cursor > 0 && !text.includes("\n");
364
449
  const slashPrefix = isSlashContext ? text.slice(1).toLowerCase() : "";
365
- const atContext = useMemo(() => (isSlashContext ? null : findAtContext(text, cursor)), [text, cursor, isSlashContext]);
450
+ const atContext = useMemo(() => resolveComposerAtContext(text, cursor, isSlashContext, allowWorkspaceMentions), [allowWorkspaceMentions, text, cursor, isSlashContext]);
366
451
  useEffect(() => {
367
452
  if (!atContext || projectFiles !== null || loadingFilesRef.current)
368
453
  return;
@@ -390,6 +475,8 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
390
475
  commands.set(command.name, command);
391
476
  }
392
477
  for (const command of slashRegistry.list()) {
478
+ if (allowedSlashCommands && !allowedSlashCommands.has(command.name))
479
+ continue;
393
480
  if (!commands.has(command.name))
394
481
  commands.set(command.name, command);
395
482
  }
@@ -398,24 +485,28 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
398
485
  name: command.name,
399
486
  description: command.description,
400
487
  }));
401
- const skillSuggestions = (skillRegistry?.summaries() ?? []).map((skill) => ({
488
+ const skillSuggestions = (allowedSlashCommands ? [] : (skillRegistry?.summaries() ?? [])).map((skill) => ({
402
489
  type: "skill",
403
490
  name: skill.name,
404
491
  description: skill.description,
405
492
  }));
406
493
  const all = [...commandSuggestions, ...skillSuggestions];
407
494
  return all.filter((item) => item.name.toLowerCase().startsWith(slashPrefix));
408
- }, [isSlashContext, slashPrefix, skillRegistry, localSlashCommands]);
495
+ }, [allowedSlashCommands, isSlashContext, slashPrefix, skillRegistry, localSlashCommands]);
409
496
  const knownSlashCommandNames = useMemo(() => {
410
497
  const names = new Set();
411
498
  for (const command of localSlashCommands)
412
499
  names.add(command.name);
413
- for (const command of slashRegistry.list())
414
- names.add(command.name);
415
- for (const skill of skillRegistry?.summaries() ?? [])
416
- names.add(skill.name);
500
+ for (const command of slashRegistry.list()) {
501
+ if (!allowedSlashCommands || allowedSlashCommands.has(command.name))
502
+ names.add(command.name);
503
+ }
504
+ if (!allowedSlashCommands) {
505
+ for (const skill of skillRegistry?.summaries() ?? [])
506
+ names.add(skill.name);
507
+ }
417
508
  return names;
418
- }, [skillRegistry, localSlashCommands]);
509
+ }, [allowedSlashCommands, skillRegistry, localSlashCommands]);
419
510
  const slashCommandHighlight = useMemo(() => resolveSlashCommandHighlightRange(text, knownSlashCommandNames), [text, knownSlashCommandNames]);
420
511
  const fileSuggestions = useMemo(() => {
421
512
  if (!atContext || !projectFiles)
@@ -437,14 +528,8 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
437
528
  const insertTextAtCursor = React.useCallback((insertion) => {
438
529
  if (!insertion)
439
530
  return;
440
- setText((prev) => {
441
- const c = cursor;
442
- const before = prev.slice(0, c);
443
- const after = prev.slice(c);
444
- return before + insertion + after;
445
- });
446
- setCursor((c) => c + insertion.length);
447
- }, [cursor]);
531
+ updateComposerBuffer((current) => insertComposerText(current, insertion));
532
+ }, [updateComposerBuffer]);
448
533
  const addAttachment = React.useCallback((att) => {
449
534
  const base = imageLabelStartOverride ?? nextImageLabelStart;
450
535
  const index = attachmentCountRef.current;
@@ -463,8 +548,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
463
548
  // terminal has nothing textual to deliver. Probe the clipboard; if it yields
464
549
  // an image, treat the paste as an image attachment. macOS only — Linux/Win
465
550
  // terminals don't reliably emit empty pastes on image-only clipboards.
466
- const tryClipboardImage = React.useCallback(async () => {
551
+ const tryClipboardImage = React.useCallback(async (expectedDraftEpoch) => {
467
552
  const { attachment, error } = await ingestClipboardImage();
553
+ if (draftIdentityEpochRef.current !== expectedDraftEpoch)
554
+ return false;
468
555
  if (attachment) {
469
556
  addAttachment(attachment);
470
557
  return true;
@@ -475,12 +562,13 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
475
562
  return false;
476
563
  }, [addAttachment, notice]);
477
564
  usePaste((pasted) => {
478
- pastePendingRef.current = true;
565
+ const pasteOperationId = pasteOperationsRef.current.begin();
566
+ const pasteDraftEpoch = draftIdentityEpochRef.current;
479
567
  // Clear the ref after React has committed the paste-driven setState.
480
568
  // setTimeout with 0 runs after the current discreteUpdates batch flushes.
481
569
  const clearPending = () => {
482
570
  setTimeout(() => {
483
- pastePendingRef.current = false;
571
+ pasteOperationsRef.current.finish(pasteOperationId);
484
572
  }, 0);
485
573
  };
486
574
  // Strip orphaned focus-event tails that can appear if focus reporting
@@ -493,10 +581,29 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
493
581
  .replace(/\x1b\[I$/, "")
494
582
  .replace(/\x1b\[O$/, "")
495
583
  .replace(/\r\n?/g, "\n");
584
+ if (!allowImageAttachments) {
585
+ // A constrained text-only runtime must not probe the clipboard or read
586
+ // pasted filesystem paths. Do not leak image path strings as chat text.
587
+ const containsImagePath = clean.length > 0 && (bareImageFilenameFromPaste(clean) !== null
588
+ || extractImagePathTokens(clean).length > 0);
589
+ if (clean.length === 0 || containsImagePath) {
590
+ notice("image attachments are disabled in this runtime session");
591
+ }
592
+ else {
593
+ if (shouldCollapsePastedContent(clean)) {
594
+ updateComposerBuffer((current) => insertComposerPaste(current, clean));
595
+ }
596
+ else {
597
+ insertTextAtCursor(clean);
598
+ }
599
+ }
600
+ clearPending();
601
+ return;
602
+ }
496
603
  // Empty paste on macOS usually means "Cmd+V with an image on the clipboard".
497
604
  if (clean.length === 0) {
498
605
  if (process.platform === "darwin") {
499
- void tryClipboardImage().finally(clearPending);
606
+ void tryClipboardImage(pasteDraftEpoch).finally(clearPending);
500
607
  }
501
608
  else {
502
609
  clearPending();
@@ -509,10 +616,11 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
509
616
  // quietly.
510
617
  const bareName = bareImageFilenameFromPaste(clean);
511
618
  if (bareName && process.platform === "darwin") {
512
- void tryClipboardImage()
619
+ void tryClipboardImage(pasteDraftEpoch)
513
620
  .then((attached) => {
514
- if (!attached)
621
+ if (draftIdentityEpochRef.current === pasteDraftEpoch && !attached) {
515
622
  insertTextAtCursor(clean);
623
+ }
516
624
  })
517
625
  .finally(clearPending);
518
626
  return;
@@ -525,9 +633,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
525
633
  if (imageTokens.length === 0) {
526
634
  // Plain text paste — insert into the input at the cursor.
527
635
  if (shouldCollapsePastedContent(clean)) {
528
- const marker = createPastedContentMarker(clean, nextPastedContentIndexRef.current++);
529
- setPastedContentRefs((prev) => [...prev, { marker, content: clean }]);
530
- insertTextAtCursor(marker);
636
+ updateComposerBuffer((current) => insertComposerPaste(current, clean));
531
637
  }
532
638
  else {
533
639
  insertTextAtCursor(clean);
@@ -537,6 +643,8 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
537
643
  }
538
644
  const handle = async () => {
539
645
  const results = await Promise.all(imageTokens.map((t) => ingestImagePath(t)));
646
+ if (draftIdentityEpochRef.current !== pasteDraftEpoch)
647
+ return;
540
648
  const successful = [];
541
649
  const errors = [];
542
650
  for (let i = 0; i < results.length; i++) {
@@ -554,10 +662,12 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
554
662
  if (successful.length === 0 &&
555
663
  process.platform === "darwin" &&
556
664
  imageTokens.some(isScreenshotTempPath)) {
557
- const clipOk = await tryClipboardImage();
665
+ const clipOk = await tryClipboardImage(pasteDraftEpoch);
558
666
  if (clipOk)
559
667
  return;
560
668
  }
669
+ if (draftIdentityEpochRef.current !== pasteDraftEpoch)
670
+ return;
561
671
  for (const att of successful)
562
672
  addAttachment(att);
563
673
  const nonImageLines = tokens.filter((t) => !isImageFilePath(t));
@@ -576,20 +686,16 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
576
686
  const applyFileSuggestion = (selectedPath) => {
577
687
  if (!atContext)
578
688
  return;
579
- const before = text.slice(0, atContext.start);
580
- const after = text.slice(atContext.end);
581
689
  const insert = `@${selectedPath} `;
582
- const newText = before + insert + after;
583
- setText(newText);
584
- setCursor(before.length + insert.length);
690
+ updateComposerBuffer((current) => replaceComposerRange(current, atContext.start, atContext.end, insert));
585
691
  setSelectedIndex(0);
586
692
  };
587
- const submitInput = (submittedText, target = "submit") => {
693
+ const submitInput = (submittedBuffer, target = "submit") => {
588
694
  const labelStartForSubmit = imageLabelStartOverride ?? nextImageLabelStart;
589
695
  const inlineLabels = attachments.map((_, index) => imageDisplayLabel(labelStartForSubmit + index));
590
696
  // Text-paste markers expand to their content (not replayable); image labels
591
697
  // are a composer-only affordance stripped here (replayable via attachments).
592
- const pasteExpanded = expandPastedContentMarkers(submittedText, pastedContentRefs);
698
+ const pasteExpanded = expandComposerBuffer(submittedBuffer);
593
699
  const expandedText = stripInlineImageLabels(pasteExpanded, inlineLabels);
594
700
  if (expandedText.trim().length === 0 && attachments.length === 0)
595
701
  return;
@@ -613,7 +719,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
613
719
  // A collapsed text-paste marker cannot be safely replayed once its
614
720
  // in-memory reference is gone; skip those. Image-label stripping is fine to
615
721
  // replay (the attachments are stored on the history entry).
616
- if (pasteExpanded === submittedText && (expandedText.trim().length > 0 || attachments.length > 0)) {
722
+ if (!hasActiveComposerPastes(submittedBuffer) && (expandedText.trim().length > 0 || attachments.length > 0)) {
617
723
  const historyEntry = {
618
724
  text: expandedText,
619
725
  images: attachments,
@@ -627,26 +733,23 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
627
733
  return nextHistory;
628
734
  });
629
735
  }
630
- setText("");
631
- setCursor(0);
736
+ updateComposerBuffer(() => createComposerBuffer());
632
737
  setSelectedIndex(0);
633
738
  setAttachments([]);
634
739
  attachmentCountRef.current = 0;
635
740
  setImageLabelStartOverride(null);
636
- setPastedContentRefs([]);
637
- nextPastedContentIndexRef.current = 1;
638
741
  setHistoryIndex(null);
639
- historyDraftRef.current = "";
742
+ historyDraftRef.current = null;
743
+ invalidateDraftAsyncWork();
640
744
  };
641
- const applySlashEnterAction = (submittedText) => {
642
- const action = resolveSlashEnterAction(submittedText, slashSuggestions, selectedIndex);
745
+ const applySlashEnterAction = (submittedBuffer) => {
746
+ const action = resolveSlashEnterAction(submittedBuffer.text, slashSuggestions, selectedIndex);
643
747
  if (action.kind === "submit") {
644
- submitInput(submittedText);
748
+ submitInput(submittedBuffer);
645
749
  return true;
646
750
  }
647
751
  if (action.kind === "complete") {
648
- setText(action.text);
649
- setCursor(action.text.length);
752
+ updateComposerBuffer((current) => replaceComposerRange(current, 0, current.text.length, action.text));
650
753
  setSelectedIndex(0);
651
754
  return true;
652
755
  }
@@ -676,18 +779,19 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
676
779
  catch { }
677
780
  }
678
781
  const enterIntent = resolveInkEnterIntent(input, key);
782
+ if (shouldDeferComposerCommitWhilePastePending(pasteOperationsRef.current.hasPending, enterIntent, key)) {
783
+ return;
784
+ }
679
785
  if (enterIntent === "newline") {
680
- const next = insertNewlineAtCursor(text, cursor);
681
- setText(next.text);
682
- setCursor(next.cursor);
786
+ updateComposerBuffer((current) => insertComposerText(current, "\n"));
683
787
  setSelectedIndex(0);
684
788
  return;
685
789
  }
686
790
  if (enterIntent === "submit" && input && /[\r\n]/.test(input)) {
687
791
  const beforeReturn = input.split(/[\r\n]/)[0] ?? "";
688
- const nextText = text.slice(0, cursor) + beforeReturn + text.slice(cursor);
792
+ const nextBuffer = insertComposerText(composerBufferRef.current, beforeReturn);
689
793
  if (showSuggestions) {
690
- if (mode === "slash" && navigable && applySlashEnterAction(nextText)) {
794
+ if (mode === "slash" && navigable && applySlashEnterAction(nextBuffer)) {
691
795
  return;
692
796
  }
693
797
  if (mode === "file") {
@@ -699,7 +803,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
699
803
  return;
700
804
  }
701
805
  }
702
- submitInput(nextText);
806
+ submitInput(nextBuffer);
703
807
  return;
704
808
  }
705
809
  const composerArrowDirection = composerVerticalArrowDirection(key);
@@ -720,13 +824,12 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
720
824
  if (key.return || key.tab) {
721
825
  if (mode === "slash" && navigable) {
722
826
  if (key.return)
723
- applySlashEnterAction(text);
827
+ applySlashEnterAction(composerBufferRef.current);
724
828
  if (key.tab) {
725
829
  const suggestion = slashSuggestions[selectedIndex];
726
830
  if (suggestion) {
727
831
  const newText = `/${suggestion.name} `;
728
- setText(newText);
729
- setCursor(newText.length);
832
+ updateComposerBuffer((current) => replaceComposerRange(current, 0, current.text.length, newText));
730
833
  setSelectedIndex(0);
731
834
  }
732
835
  }
@@ -746,55 +849,44 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
746
849
  // While the agent runs, Tab queues the composer content for the next
747
850
  // turn (Enter steers — handled by the app-level submit routing).
748
851
  if (key.tab && !key.shift && onQueue && !showSuggestions) {
749
- if (pastePendingRef.current)
750
- return;
751
- submitInput(text, "queue");
852
+ submitInput(composerBufferRef.current, "queue");
752
853
  return;
753
854
  }
754
855
  if (enterIntent === "submit") {
755
856
  // A paste is still mid-flight — dropping this Enter avoids submitting
756
857
  // an input state that doesn't yet include the paste.
757
- if (pastePendingRef.current)
758
- return;
759
- submitInput(text);
858
+ submitInput(composerBufferRef.current);
760
859
  return;
761
860
  }
762
861
  const editAction = resolveComposerEditAction(input, key);
763
862
  if (editAction) {
764
863
  if (editAction === "word-left") {
765
- setCursor(previousWordBoundary(text, cursor));
864
+ updateComposerBuffer((current) => moveComposerCursor(current, previousWordBoundary(current.text, current.cursor), "left"));
766
865
  }
767
866
  else if (editAction === "word-right") {
768
- setCursor(nextWordBoundary(text, cursor));
867
+ updateComposerBuffer((current) => moveComposerCursor(current, nextWordBoundary(current.text, current.cursor), "right"));
769
868
  }
770
869
  else if (editAction === "line-start") {
771
- setCursor(lineStartBoundary(text, cursor));
870
+ updateComposerBuffer((current) => moveComposerCursor(current, lineStartBoundary(current.text, current.cursor), "left"));
772
871
  }
773
872
  else if (editAction === "line-end") {
774
- setCursor(lineEndBoundary(text, cursor));
873
+ updateComposerBuffer((current) => moveComposerCursor(current, lineEndBoundary(current.text, current.cursor), "right"));
775
874
  }
776
875
  else if (editAction === "delete-line-start") {
777
- const next = deleteToLineStart(text, cursor);
778
- setText(next.text);
779
- setCursor(next.cursor);
876
+ updateComposerBuffer((current) => replaceComposerRange(current, lineStartBoundary(current.text, current.cursor), current.cursor));
780
877
  }
781
878
  else {
782
- const next = deleteToLineEnd(text, cursor);
783
- setText(next.text);
784
- setCursor(next.cursor);
879
+ updateComposerBuffer((current) => replaceComposerRange(current, current.cursor, lineEndBoundary(current.text, current.cursor)));
785
880
  }
786
881
  setSelectedIndex(0);
787
882
  return;
788
883
  }
789
884
  if (key.backspace) {
790
- if (cursor > 0) {
791
- const before = text.slice(0, cursor - 1);
792
- const after = text.slice(cursor);
793
- setText(before + after);
794
- setCursor(cursor - 1);
885
+ if (composerBufferRef.current.cursor > 0) {
886
+ updateComposerBuffer((current) => deleteComposerBackward(current));
795
887
  setSelectedIndex(0);
796
888
  }
797
- else if (attachments.length > 0) {
889
+ else if (attachmentCountRef.current > 0) {
798
890
  // Backspace at position 0 drops the most recent attachment so users
799
891
  // can undo a misfired paste without submitting the message.
800
892
  setAttachments((prev) => {
@@ -808,21 +900,17 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
808
900
  return;
809
901
  }
810
902
  if (key.delete) {
811
- if (cursor < text.length) {
812
- const next = deleteAtCursor(text, cursor);
813
- setText(next.text);
814
- setCursor(next.cursor);
815
- setSelectedIndex(0);
816
- }
903
+ updateComposerBuffer((current) => deleteComposerForward(current));
904
+ setSelectedIndex(0);
817
905
  return;
818
906
  }
819
907
  if (key.leftArrow) {
820
- setCursor(Math.max(0, cursor - 1));
908
+ updateComposerBuffer((current) => moveComposerCursor(current, current.cursor - 1, "left"));
821
909
  setSelectedIndex(0);
822
910
  return;
823
911
  }
824
912
  if (key.rightArrow) {
825
- setCursor(Math.min(text.length, cursor + 1));
913
+ updateComposerBuffer((current) => moveComposerCursor(current, current.cursor + 1, "right"));
826
914
  setSelectedIndex(0);
827
915
  return;
828
916
  }
@@ -841,10 +929,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
841
929
  const printable = input.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
842
930
  if (!printable)
843
931
  return;
844
- const before = text.slice(0, cursor);
845
- const after = text.slice(cursor);
846
- setText(before + printable + after);
847
- setCursor(cursor + printable.length);
932
+ updateComposerBuffer((current) => insertComposerText(current, printable));
848
933
  setSelectedIndex(0);
849
934
  }
850
935
  });
@@ -882,18 +967,16 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
882
967
  if (lastDraftEpochRef.current === draftEpoch)
883
968
  return;
884
969
  lastDraftEpochRef.current = draftEpoch;
885
- if (!draftText)
970
+ if (draftText === undefined)
886
971
  return;
887
- setText(draftText);
888
- setCursor(draftText.length);
972
+ updateComposerBuffer(() => createComposerBuffer(draftText));
889
973
  setSelectedIndex(0);
890
974
  setAttachments([]);
891
975
  attachmentCountRef.current = 0;
892
976
  setImageLabelStartOverride(null);
893
- setPastedContentRefs([]);
894
- nextPastedContentIndexRef.current = 1;
895
977
  setHistoryIndex(null);
896
- historyDraftRef.current = "";
978
+ historyDraftRef.current = null;
979
+ invalidateDraftAsyncWork();
897
980
  onDraftApplied?.();
898
981
  }, [draftEpoch, draftText, onDraftApplied]);
899
982
  useEffect(() => {
@@ -948,30 +1031,40 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
948
1031
  // bottom edge (Down → next message, then back to the in-progress draft).
949
1032
  const performVerticalArrowRef = useRef(() => { });
950
1033
  performVerticalArrowRef.current = (direction) => {
1034
+ // A paste and the following key can arrive in one stdin batch. Wait for
1035
+ // that paste transition to commit before moving or snapshotting history.
1036
+ if (pasteOperationsRef.current.hasPending)
1037
+ return;
951
1038
  if (direction === "up") {
952
1039
  if (cursorVisualRow > 0) {
953
- setCursor(displayCursorToSourceCursor(visualToCursor(visualLines, cursorVisualRow - 1, cursorVisualCol)));
1040
+ const requested = displayCursorToSourceCursor(visualToCursor(visualLines, cursorVisualRow - 1, cursorVisualCol));
1041
+ updateComposerBuffer((current) => moveComposerCursor(current, requested, "nearest"));
954
1042
  return;
955
1043
  }
956
1044
  }
957
1045
  else {
958
1046
  if (cursorVisualRow < visualLines.length - 1) {
959
- setCursor(displayCursorToSourceCursor(visualToCursor(visualLines, cursorVisualRow + 1, cursorVisualCol)));
1047
+ const requested = displayCursorToSourceCursor(visualToCursor(visualLines, cursorVisualRow + 1, cursorVisualCol));
1048
+ updateComposerBuffer((current) => moveComposerCursor(current, requested, "nearest"));
960
1049
  return;
961
1050
  }
962
1051
  }
963
- const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, direction, { text, images: attachments });
1052
+ const result = stepComposerHistory({
1053
+ buffer: composerBufferRef.current,
1054
+ attachments,
1055
+ imageLabelStartOverride,
1056
+ historyIndex,
1057
+ draftSnapshot: historyDraftRef.current,
1058
+ }, history, direction);
964
1059
  if (result.changed) {
965
- setText(result.text);
966
- setCursor(result.text.length);
967
- setAttachments(result.images ?? []);
968
- attachmentCountRef.current = (result.images ?? []).length;
969
- setImageLabelStartOverride(result.imageDisplayStart ?? null);
970
- setHistoryIndex(result.index);
971
- historyDraftRef.current = result.draft;
1060
+ updateComposerBuffer(() => result.buffer);
1061
+ setAttachments(result.attachments);
1062
+ attachmentCountRef.current = result.attachments.length;
1063
+ setImageLabelStartOverride(result.imageLabelStartOverride);
1064
+ setHistoryIndex(result.historyIndex);
1065
+ historyDraftRef.current = result.draftSnapshot;
972
1066
  setSelectedIndex(0);
973
- setPastedContentRefs([]);
974
- nextPastedContentIndexRef.current = 1;
1067
+ invalidateDraftAsyncWork();
975
1068
  }
976
1069
  else if (direction === "down") {
977
1070
  // At the bottom edge with nothing newer in history: hand Down to the