@bubblebrain-ai/bubble 0.0.19 → 0.0.21

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 (96) hide show
  1. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
  2. package/dist/agent/internal-reminder-sanitizer.js +46 -0
  3. package/dist/agent.d.ts +10 -0
  4. package/dist/agent.js +310 -18
  5. package/dist/approval/controller.d.ts +6 -0
  6. package/dist/approval/controller.js +104 -11
  7. package/dist/checkpoints.d.ts +57 -0
  8. package/dist/checkpoints.js +0 -0
  9. package/dist/debug-trace.js +4 -0
  10. package/dist/feishu/agent-host/run-driver.js +29 -0
  11. package/dist/hooks/config.d.ts +9 -0
  12. package/dist/hooks/config.js +278 -0
  13. package/dist/hooks/controller.d.ts +24 -0
  14. package/dist/hooks/controller.js +254 -0
  15. package/dist/hooks/index.d.ts +6 -0
  16. package/dist/hooks/index.js +4 -0
  17. package/dist/hooks/log.d.ts +14 -0
  18. package/dist/hooks/log.js +54 -0
  19. package/dist/hooks/runner.d.ts +5 -0
  20. package/dist/hooks/runner.js +225 -0
  21. package/dist/hooks/trust.d.ts +37 -0
  22. package/dist/hooks/trust.js +143 -0
  23. package/dist/hooks/types.d.ts +173 -0
  24. package/dist/hooks/types.js +46 -0
  25. package/dist/main.js +86 -13
  26. package/dist/memory/prompts.js +3 -1
  27. package/dist/model-catalog.js +2 -0
  28. package/dist/model-pricing.js +8 -0
  29. package/dist/network/chatgpt-transport.d.ts +0 -1
  30. package/dist/network/chatgpt-transport.js +40 -121
  31. package/dist/network/provider-transport.d.ts +32 -0
  32. package/dist/network/provider-transport.js +265 -0
  33. package/dist/network/retry.d.ts +29 -0
  34. package/dist/network/retry.js +88 -0
  35. package/dist/network/system-proxy.d.ts +18 -0
  36. package/dist/network/system-proxy.js +175 -0
  37. package/dist/provider-anthropic.d.ts +1 -0
  38. package/dist/provider-anthropic.js +127 -52
  39. package/dist/provider-openai-codex.js +19 -29
  40. package/dist/session-log.js +3 -3
  41. package/dist/session.d.ts +31 -0
  42. package/dist/session.js +69 -0
  43. package/dist/slash-commands/commands.js +164 -0
  44. package/dist/slash-commands/types.d.ts +6 -0
  45. package/dist/tools/bash.js +4 -0
  46. package/dist/tools/edit-apply.js +63 -3
  47. package/dist/tools/edit.d.ts +2 -1
  48. package/dist/tools/edit.js +6 -5
  49. package/dist/tools/index.d.ts +7 -0
  50. package/dist/tools/index.js +2 -2
  51. package/dist/tools/write.d.ts +2 -1
  52. package/dist/tools/write.js +2 -1
  53. package/dist/tui/display-history.d.ts +4 -3
  54. package/dist/tui/display-history.js +34 -57
  55. package/dist/tui/display-sanitizer.d.ts +3 -0
  56. package/dist/tui/display-sanitizer.js +38 -0
  57. package/dist/tui/image-paste.d.ts +18 -0
  58. package/dist/tui/image-paste.js +60 -0
  59. package/dist/tui/paste-placeholder.d.ts +1 -0
  60. package/dist/tui/paste-placeholder.js +7 -0
  61. package/dist/tui/run.d.ts +2 -0
  62. package/dist/tui/run.js +568 -223
  63. package/dist/tui/trace-groups.d.ts +16 -0
  64. package/dist/tui/trace-groups.js +82 -5
  65. package/dist/tui/transcript-scroll.d.ts +25 -0
  66. package/dist/tui/transcript-scroll.js +20 -0
  67. package/dist/tui/wordmark.d.ts +1 -0
  68. package/dist/tui/wordmark.js +56 -54
  69. package/dist/tui-ink/app.d.ts +4 -1
  70. package/dist/tui-ink/app.js +303 -248
  71. package/dist/tui-ink/display-history.d.ts +16 -1
  72. package/dist/tui-ink/display-history.js +50 -21
  73. package/dist/tui-ink/footer.d.ts +6 -12
  74. package/dist/tui-ink/footer.js +10 -29
  75. package/dist/tui-ink/image-paste.d.ts +59 -0
  76. package/dist/tui-ink/image-paste.js +277 -0
  77. package/dist/tui-ink/input-box.d.ts +26 -1
  78. package/dist/tui-ink/input-box.js +171 -41
  79. package/dist/tui-ink/message-list.d.ts +1 -1
  80. package/dist/tui-ink/message-list.js +46 -29
  81. package/dist/tui-ink/run.d.ts +7 -2
  82. package/dist/tui-ink/run.js +73 -23
  83. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  84. package/dist/tui-ink/terminal-mouse.js +4 -0
  85. package/dist/tui-ink/trace-groups.d.ts +16 -0
  86. package/dist/tui-ink/trace-groups.js +90 -6
  87. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  88. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  89. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  90. package/dist/tui-ink/transcript-viewport.js +83 -0
  91. package/dist/tui-ink/welcome.d.ts +9 -7
  92. package/dist/tui-ink/welcome.js +7 -33
  93. package/dist/tui-opentui/app.js +2 -1
  94. package/dist/tui-opentui/trace-groups.js +40 -4
  95. package/dist/types.d.ts +27 -0
  96. package/package.json +1 -1
package/dist/tui/run.js CHANGED
@@ -6,11 +6,11 @@ import qrTerminal from "qrcode-terminal";
6
6
  import { existsSync, statSync } from "node:fs";
7
7
  import { basename, isAbsolute, resolve as resolvePath } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { AgentAbortError } from "../agent.js";
9
+ import { AgentAbortError, INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
10
10
  import { AgentRunInputQueue } from "../agent/input-controller.js";
11
11
  import { debugReasoningStream, summarizeDebugText } from "../reasoning-debug.js";
12
12
  import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
13
- import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
13
+ import { createStreamingInternalReminderSanitizer, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "../agent/internal-reminder-sanitizer.js";
14
14
  import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceValue, traceEvent, } from "../debug-trace.js";
15
15
  import { BUILTIN_PROVIDERS, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
16
16
  import { calculateUsageCost } from "../model-pricing.js";
@@ -22,7 +22,8 @@ import { registry as slashRegistry } from "../slash-commands/index.js";
22
22
  import { sourceRank } from "../slash-commands/unified.js";
23
23
  import { sidebarMcpRowsFromStates, renderMcpRowMarker } from "./sidebar-mcp.js";
24
24
  import { expandAtMentions, filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
25
- import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
25
+ import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, setUserInputStatus, snapshotDisplayParts, toolCallsFromParts, userInputStatusBadgeLabel, } from "./display-history.js";
26
+ import { sanitizeDisplayMessage, sanitizeDisplayMessages } from "./display-sanitizer.js";
26
27
  import { createMarkdownSyntaxStyle, createSubtleMarkdownSyntaxStyle } from "./markdown-theme.js";
27
28
  import { markdownInlineSegments } from "./markdown-inline.js";
28
29
  import { hashString } from "./render-signature.js";
@@ -40,14 +41,16 @@ import { submitFeedback, FeedbackSubmitError } from "../feedback/submit.js";
40
41
  import { createFrames } from "./opencode-spinner.js";
41
42
  import { copyTextToClipboard } from "./clipboard.js";
42
43
  import { readGitSidebarState } from "./sidebar-state.js";
43
- import { buildImageContentPartsFromLabels, extractImagePathTokens, imageAttachmentLabelPattern, resolveComposerImagePaths, resolveImageInput, } from "./image-paste.js";
44
+ import { buildImageContentPartsFromLabels, bareImageFilenameFromPaste, extractImagePathTokens, imageAttachmentLabelPattern, imageLabelForPath, ingestClipboardImage, ingestImagePath, isImagePathPaste, splitPastedPaths, resolveComposerImagePaths, resolveImageInput, } from "./image-paste.js";
45
+ import { createPastedContentMarker, decodePastedBytes, expandPastedContentMarkers, shouldCollapsePastedContent, } from "./paste-placeholder.js";
44
46
  import { isModeCycleKeyEvent, isModeCycleSequence, isModifiedEnterSequence, PROMPT_TEXTAREA_KEYBINDINGS, } from "./prompt-keybindings.js";
45
47
  import { keyNameFromEvent, keyNameFromSequence } from "./global-key-router.js";
46
48
  import { EscapeConfirmationGate } from "./escape-confirmation.js";
47
49
  import { appendHistoryEntry, loadHistorySync, pushHistoryEntry } from "./input-history.js";
48
- import { buildTraceGroups, traceGroupLabel } from "./trace-groups.js";
50
+ import { buildTraceGroups, executeCommandBlock, shouldInlineExecuteCommand, traceGroupLabel, } from "./trace-groups.js";
49
51
  import { sessionDisplayName } from "./session-display.js";
50
52
  import { bubbleWordmarkForWidth, bubbleWordmarkLineText, } from "./wordmark.js";
53
+ import { resolveTranscriptScroll } from "./transcript-scroll.js";
51
54
  import { bootstrapConfig } from "../feishu/config.js";
52
55
  import { ScopeRegistry } from "../feishu/scope/scope-registry.js";
53
56
  const treeSitterClient = getTreeSitterClient();
@@ -71,6 +74,8 @@ const DEFAULT_THEME = {
71
74
  info: "#56b6c2",
72
75
  text: "#eeeeee",
73
76
  textMuted: "#808080",
77
+ selectionBg: "#3D5066",
78
+ selectionFg: "#eeeeee",
74
79
  background: "#0a0a0a",
75
80
  backgroundPanel: "#141414",
76
81
  backgroundElement: "#1e1e1e",
@@ -113,6 +118,8 @@ const LIGHT_THEME = {
113
118
  info: "#257E8A",
114
119
  text: "#171717",
115
120
  textMuted: "#6F7377",
121
+ selectionBg: "#B9D4F7",
122
+ selectionFg: "#171717",
116
123
  background: "#FCFCFA",
117
124
  backgroundPanel: "#F6F6F3",
118
125
  backgroundElement: "#ECEDEA",
@@ -186,6 +193,9 @@ const PROMPT_SCANNER_INTERVAL_MS = 80;
186
193
  const SESSION_SIDEBAR_WIDTH = 42;
187
194
  const SESSION_SIDEBAR_AUTO_WIDTH = 120;
188
195
  const PROVIDER_DIALOG_ROWS = 13;
196
+ const PROVIDER_DIALOG_MIN_WIDTH = 56;
197
+ const PROVIDER_DIALOG_MAX_WIDTH = 84;
198
+ const PROVIDER_DIALOG_ROW_RESERVED_WIDTH = 10;
189
199
  const QUESTION_MAX_TABS = 4;
190
200
  const QUESTION_MAX_OPTIONS = 10;
191
201
  const QUESTION_MAX_CONFIRM_ROWS = 3;
@@ -221,6 +231,7 @@ const HOME_TIPS = [
221
231
  "Use /compact to summarize long sessions near context limits",
222
232
  "Shift+Enter or Ctrl+J inserts a newline in your prompt",
223
233
  ];
234
+ const SELECTABLE_TEXT_TAGS = new Set(["text", "textarea", "code", "markdown", "diff", "input"]);
224
235
  function h(tag, props, ...children) {
225
236
  const allProps = props ?? {};
226
237
  const childList = children.length > 0 ? children : allProps.children !== undefined ? [allProps.children] : [];
@@ -232,6 +243,14 @@ function h(tag, props, ...children) {
232
243
  }
233
244
  const element = createElement(tag);
234
245
  const { children: _children, ...rest } = allProps;
246
+ // Without explicit selection colors OpenTUI inverts fg/bg; with our
247
+ // transparent backgrounds that degrades to black-on-black on light themes.
248
+ if (SELECTABLE_TEXT_TAGS.has(tag)) {
249
+ if (rest.selectionBg === undefined)
250
+ rest.selectionBg = theme.selectionBg;
251
+ if (rest.selectionFg === undefined)
252
+ rest.selectionFg = theme.selectionFg;
253
+ }
235
254
  spread(element, rest, false);
236
255
  if (childList.length === 1)
237
256
  insert(element, childList[0]);
@@ -239,6 +258,27 @@ function h(tag, props, ...children) {
239
258
  insert(element, childList);
240
259
  return element;
241
260
  }
261
+ // OpenTUI hardcodes updateCursor=true for mouse-driven selection, so dragging
262
+ // a selection yanks the editor cursor to the drag focus. Keep plain clicks
263
+ // (empty selection) positioning the cursor and keyboard selection intact, but
264
+ // freeze the cursor while a real range is being dragged.
265
+ function preserveCursorOnMouseSelection(ref) {
266
+ const editor = ref?.editorView;
267
+ if (!editor || editor.__bubbleSelectionCursorPatch)
268
+ return;
269
+ editor.__bubbleSelectionCursorPatch = true;
270
+ for (const method of ["setLocalSelection", "updateLocalSelection"]) {
271
+ const original = editor[method]?.bind(editor);
272
+ if (!original)
273
+ continue;
274
+ editor[method] = (anchorX, anchorY, focusX, focusY, bg, fg, updateCursor, followCursor) => {
275
+ const keyboardDriven = ref?._keyboardSelectionActive === true;
276
+ const emptySelection = anchorX === focusX && anchorY === focusY;
277
+ const allowCursorMove = keyboardDriven || emptySelection;
278
+ return original(anchorX, anchorY, focusX, focusY, bg, fg, allowCursorMove ? updateCursor : false, allowCursorMove ? followCursor : false);
279
+ };
280
+ }
281
+ }
242
282
  function isDestroyedRenderable(ref) {
243
283
  return !ref || ref.isDestroyed === true;
244
284
  }
@@ -438,6 +478,15 @@ function OpenTuiApp(props) {
438
478
  let promptHistory = initialPromptHistory(displayMessages);
439
479
  let nextImageAttachmentIndex = nextImageLabelIndex(displayMessages);
440
480
  const pendingImageAttachments = new Map();
481
+ // Image-path pastes insert their [image#N] label immediately and ingest the
482
+ // file in the background; sends await these so a quick Enter can't outrun
483
+ // attachment registration.
484
+ const pendingImageIngestions = new Set();
485
+ // Long pastes are collapsed to "[Pasted text #N +M lines]" in the composer
486
+ // and expanded back to the full content when the message is submitted,
487
+ // mirroring how image attachments use "[Image #N]" labels.
488
+ const pendingPastedTexts = new Map();
489
+ let nextPastedTextIndex = 1;
441
490
  let composerImageResolutionSeq = 0;
442
491
  let applyingComposerImageReplacement = false;
443
492
  let promptHistoryIndex;
@@ -467,7 +516,7 @@ function OpenTuiApp(props) {
467
516
  let copyToastRoot;
468
517
  let copyToastText;
469
518
  const [sessionActive, setSessionActive] = createSignal(false);
470
- const [sidebarMode, setSidebarModeState] = createSignal("auto");
519
+ const [sidebarMode, setSidebarModeState] = createSignal("collapsed");
471
520
  const [sidebarTick, setSidebarTick] = createSignal(0);
472
521
  // Sidebar MCP section collapsed state. Persisted across sidebarTick bumps,
473
522
  // only reset on actual mount. Collapse toggle exposed when > 2 servers.
@@ -502,11 +551,14 @@ function OpenTuiApp(props) {
502
551
  let providerDialogModelItems;
503
552
  let providerDialogModelRefreshId = 0;
504
553
  let previousPickerForKey;
505
- let homePromptRef;
506
554
  let sessionPromptRef;
507
555
  let scrollbox;
508
556
  let transcriptScrollFollowing = true;
509
557
  let transcriptScrollInitialized = false;
558
+ // Set by forceFollow renders (user sends, approvals). Survives intervening
559
+ // streaming redraws that recompute follow state from the (still-unscrolled)
560
+ // position before the deferred scroll runs; cleared on user mouse scroll.
561
+ let transcriptForceScrollPending = false;
510
562
  let rootBox;
511
563
  let sidebarShell;
512
564
  let homeSurfaceShell;
@@ -518,7 +570,6 @@ function OpenTuiApp(props) {
518
570
  defaultWritesExpanded: false,
519
571
  };
520
572
  let dock;
521
- let homeComposerShell;
522
573
  let sessionComposerShell;
523
574
  const promptScannerSyncs = new Set();
524
575
  let approvalRoot;
@@ -596,8 +647,6 @@ function OpenTuiApp(props) {
596
647
  const providerDialogFooters = [];
597
648
  const promptModeLabels = new Set();
598
649
  const promptModelLabels = new Set();
599
- let footerModeBadge;
600
- let footerTraceBadge;
601
650
  let sidebarTokenText;
602
651
  let sidebarPercentText;
603
652
  let sidebarGaugeText;
@@ -619,9 +668,7 @@ function OpenTuiApp(props) {
619
668
  const sidebarFileAdditions = [];
620
669
  const sidebarFileDeletions = [];
621
670
  let sidebarFileSection;
622
- const activePrompt = () => isHomeSurfaceActive()
623
- ? homePromptRef ?? sessionPromptRef
624
- : sessionPromptRef ?? homePromptRef;
671
+ const activePrompt = () => sessionPromptRef;
625
672
  function setPromptText(value) {
626
673
  promptText = value;
627
674
  const prompt = activePrompt();
@@ -703,7 +750,6 @@ function OpenTuiApp(props) {
703
750
  return true;
704
751
  }
705
752
  function blurInputsForModal() {
706
- homePromptRef?.blur();
707
753
  sessionPromptRef?.blur();
708
754
  questionCustomInput?.blur();
709
755
  providerDialogInput?.blur();
@@ -760,9 +806,7 @@ function OpenTuiApp(props) {
760
806
  activePrompt()?.focus();
761
807
  }, 0);
762
808
  }
763
- const activeComposerShell = () => isHomeSurfaceActive()
764
- ? homeComposerShell ?? sessionComposerShell
765
- : sessionComposerShell ?? homeComposerShell;
809
+ const activeComposerShell = () => sessionComposerShell;
766
810
  onCleanup(() => {
767
811
  uiDisposed = true;
768
812
  if (copyToastClearTimer)
@@ -774,7 +818,6 @@ function OpenTuiApp(props) {
774
818
  feishuSetupAbortController?.abort();
775
819
  promptModeLabels.clear();
776
820
  promptModelLabels.clear();
777
- footerModeBadge = undefined;
778
821
  });
779
822
  function showCopyToast(toast, ttl = 2200) {
780
823
  if (copyToastClearTimer)
@@ -894,6 +937,23 @@ function OpenTuiApp(props) {
894
937
  return dimensions().width > SESSION_SIDEBAR_AUTO_WIDTH;
895
938
  };
896
939
  const contentWidth = () => Math.max(20, dimensions().width - (sidebarVisible() ? SESSION_SIDEBAR_WIDTH : 0) - 4);
940
+ const liveTerminalDimensions = () => {
941
+ const reactive = dimensions();
942
+ // Some terminal split-pane flows leave OpenTUI's resize signal stale. Node's
943
+ // TTY size is sampled on demand, so use it for modal geometry when present.
944
+ const stdoutWidth = process.stdout.columns;
945
+ const stdoutHeight = process.stdout.rows;
946
+ const width = Number.isFinite(stdoutWidth) && stdoutWidth && stdoutWidth > 0
947
+ ? stdoutWidth
948
+ : reactive.width;
949
+ const height = Number.isFinite(stdoutHeight) && stdoutHeight && stdoutHeight > 0
950
+ ? stdoutHeight
951
+ : reactive.height;
952
+ return {
953
+ width: Math.max(1, Math.floor(width)),
954
+ height: Math.max(1, Math.floor(height)),
955
+ };
956
+ };
897
957
  const bumpSidebar = () => {
898
958
  setSidebarTick((value) => value + 1);
899
959
  syncSidebarContext();
@@ -1075,9 +1135,7 @@ function OpenTuiApp(props) {
1075
1135
  }
1076
1136
  const promptModeTitle = () => mode() === "plan" ? "Plan" : "Build";
1077
1137
  const promptModeBadge = () => promptModeBadgeContent(mode());
1078
- const footerModeText = () => footerPermissionModeText(mode());
1079
1138
  const effectiveShowThinking = () => showThinking() || verboseTrace();
1080
- const footerTraceText = () => footerTraceModeText(verboseTrace());
1081
1139
  function syncModeChrome() {
1082
1140
  if (uiDisposed)
1083
1141
  return;
@@ -1085,23 +1143,12 @@ function OpenTuiApp(props) {
1085
1143
  if (!safeSetText(label, promptModeBadge()))
1086
1144
  promptModeLabels.delete(label);
1087
1145
  }
1088
- if (footerModeBadge) {
1089
- footerModeBadge.fg = permissionModeColor(mode());
1090
- if (!safeSetText(footerModeBadge, footerModeText()))
1091
- footerModeBadge = undefined;
1092
- }
1093
- safeRequestRender(homeComposerShell);
1094
1146
  safeRequestRender(sessionComposerShell);
1095
1147
  safeRequestRender(rootBox);
1096
1148
  }
1097
1149
  function syncTraceChrome() {
1098
1150
  if (uiDisposed)
1099
1151
  return;
1100
- if (footerTraceBadge) {
1101
- footerTraceBadge.fg = verboseTrace() ? theme.warning : theme.textMuted;
1102
- if (!safeSetText(footerTraceBadge, footerTraceText()))
1103
- footerTraceBadge = undefined;
1104
- }
1105
1152
  safeRequestRender(rootBox);
1106
1153
  }
1107
1154
  const registerPromptModeLabel = (ref) => {
@@ -1119,7 +1166,6 @@ function OpenTuiApp(props) {
1119
1166
  if (!safeSetText(label, promptModelTitle()))
1120
1167
  promptModelLabels.delete(label);
1121
1168
  }
1122
- safeRequestRender(homeComposerShell);
1123
1169
  safeRequestRender(sessionComposerShell);
1124
1170
  safeRequestRender(rootBox);
1125
1171
  };
@@ -1130,21 +1176,6 @@ function OpenTuiApp(props) {
1130
1176
  if (!safeSetText(ref, promptModelTitle()))
1131
1177
  promptModelLabels.delete(ref);
1132
1178
  };
1133
- const registerFooterModeBadge = (ref) => {
1134
- if (uiDisposed)
1135
- return;
1136
- footerModeBadge = ref;
1137
- if (!safeSetText(ref, footerModeText()))
1138
- footerModeBadge = undefined;
1139
- };
1140
- const registerFooterTraceBadge = (ref) => {
1141
- if (uiDisposed)
1142
- return;
1143
- footerTraceBadge = ref;
1144
- ref.fg = verboseTrace() ? theme.warning : theme.textMuted;
1145
- if (!safeSetText(ref, footerTraceText()))
1146
- footerTraceBadge = undefined;
1147
- };
1148
1179
  const cycleMode = () => {
1149
1180
  if (picker || pendingPlan() || isRunning())
1150
1181
  return false;
@@ -1367,7 +1398,7 @@ function OpenTuiApp(props) {
1367
1398
  redrawApprovalPanel();
1368
1399
  if (approval || plan)
1369
1400
  focusApprovalPanel();
1370
- redrawTranscript();
1401
+ redrawTranscript(streamingDisplay, displayMessages, { forceFollow: !!approval });
1371
1402
  };
1372
1403
  function questionStateFromRequest(request) {
1373
1404
  return {
@@ -1450,7 +1481,13 @@ function OpenTuiApp(props) {
1450
1481
  setTimeout(() => {
1451
1482
  if (!scrollbox)
1452
1483
  return;
1453
- if (shouldFollow && transcriptScrollFollowing) {
1484
+ const action = resolveTranscriptScroll({
1485
+ forcePending: transcriptForceScrollPending,
1486
+ shouldFollow,
1487
+ following: transcriptScrollFollowing,
1488
+ });
1489
+ if (action === "scroll-bottom") {
1490
+ transcriptForceScrollPending = false;
1454
1491
  scrollTranscriptToBottom();
1455
1492
  }
1456
1493
  else {
@@ -1459,6 +1496,7 @@ function OpenTuiApp(props) {
1459
1496
  }, delay);
1460
1497
  }
1461
1498
  function handleTranscriptMouseScroll() {
1499
+ transcriptForceScrollPending = false;
1462
1500
  setTimeout(updateTranscriptScrollFollowingFromPosition, 0);
1463
1501
  }
1464
1502
  function syncQuestionUI(focusCustom = false) {
@@ -2327,18 +2365,18 @@ function OpenTuiApp(props) {
2327
2365
  function isHomeSurfaceActive(extra) {
2328
2366
  return !hasTranscriptMessages(extra) && !pendingPlan() && !pendingQuestion() && !pendingFeedback() && !statsPanel && !pendingFeishuSetup();
2329
2367
  }
2368
+ function isComposerHiddenByModal() {
2369
+ return !!pendingQuestion() || !!pendingFeedback() || !!statsPanel || !!pendingFeishuSetup();
2370
+ }
2330
2371
  function syncPromptSurfaces(focus = false) {
2331
2372
  const homeActive = isHomeSurfaceActive(streamingDisplay);
2332
2373
  const nextSessionActive = !homeActive;
2333
2374
  const surfaceChanged = sessionActive() !== nextSessionActive;
2334
2375
  setSessionActive(nextSessionActive);
2335
- const modalComposerHidden = !!pendingQuestion() || !!pendingFeedback() || !!statsPanel || !!pendingFeishuSetup();
2336
2376
  if (homeSurfaceShell)
2337
2377
  homeSurfaceShell.visible = homeActive;
2338
- if (homeComposerShell)
2339
- homeComposerShell.visible = homeActive && !modalComposerHidden;
2340
2378
  if (sessionComposerShell)
2341
- sessionComposerShell.visible = !homeActive && !modalComposerHidden;
2379
+ sessionComposerShell.visible = !isComposerHiddenByModal();
2342
2380
  syncSidebarChrome();
2343
2381
  if (focus || surfaceChanged)
2344
2382
  setTimeout(() => activePrompt()?.focus(), 0);
@@ -2362,7 +2400,6 @@ function OpenTuiApp(props) {
2362
2400
  }
2363
2401
  }
2364
2402
  try {
2365
- homeComposerShell?.requestRender();
2366
2403
  sessionComposerShell?.requestRender();
2367
2404
  rootBox?.requestRender();
2368
2405
  }
@@ -2409,25 +2446,33 @@ function OpenTuiApp(props) {
2409
2446
  function queuedInputLabel(count = queuedInputCount()) {
2410
2447
  return `${count} queued message${count === 1 ? "" : "s"}`;
2411
2448
  }
2412
- function redrawTranscriptWithQueuedDisplays() {
2413
- redrawTranscript(streamingDisplay, displayMessages);
2449
+ function redrawTranscriptWithQueuedDisplays(options = {}) {
2450
+ redrawTranscript(streamingDisplay, displayMessages, options);
2414
2451
  }
2415
- function addQueuedUserDisplay(input) {
2452
+ function addUserInputStatusDisplay(input, inputStatus) {
2416
2453
  const displayId = `queued-${++nextQueuedDisplayId}`;
2417
2454
  queuedDisplayMessages = [
2418
2455
  ...queuedDisplayMessages,
2419
- { role: "user", content: input, clientId: displayId, queued: true },
2456
+ { role: "user", content: input, clientId: displayId, inputStatus },
2420
2457
  ];
2421
- redrawTranscriptWithQueuedDisplays();
2458
+ // Sending a message is explicit user intent to look at the newest turn:
2459
+ // snap to the bottom even if the transcript was scrolled up.
2460
+ redrawTranscriptWithQueuedDisplays({ forceFollow: true });
2422
2461
  return displayId;
2423
2462
  }
2424
- function updateQueuedUserDisplay(displayId, queued) {
2463
+ function addQueuedUserDisplay(input) {
2464
+ return addUserInputStatusDisplay(input, "queued");
2465
+ }
2466
+ function addPendingSteerUserDisplay(input) {
2467
+ return addUserInputStatusDisplay(input, "pending_steer");
2468
+ }
2469
+ function updateUserInputDisplayStatus(displayId, inputStatus) {
2425
2470
  let changed = false;
2426
2471
  const update = (message) => {
2427
2472
  if (message.clientId !== displayId)
2428
2473
  return message;
2429
2474
  changed = true;
2430
- return { ...message, queued };
2475
+ return setUserInputStatus(message, inputStatus);
2431
2476
  };
2432
2477
  displayMessages = displayMessages.map(update);
2433
2478
  queuedDisplayMessages = queuedDisplayMessages.map(update);
@@ -2452,11 +2497,14 @@ function OpenTuiApp(props) {
2452
2497
  return false;
2453
2498
  const index = queuedDisplayMessages.findIndex((message) => message.clientId === displayId);
2454
2499
  if (index === -1) {
2455
- return updateQueuedUserDisplay(displayId, false);
2500
+ return updateUserInputDisplayStatus(displayId);
2456
2501
  }
2457
2502
  const message = queuedDisplayMessages[index];
2458
2503
  queuedDisplayMessages = queuedDisplayMessages.filter((_, itemIndex) => itemIndex !== index);
2459
- displayMessages = [...displayMessages, { ...message, content: message.content || fallbackContent || " ", queued: false }];
2504
+ displayMessages = [
2505
+ ...displayMessages,
2506
+ setUserInputStatus({ ...message, content: message.content || fallbackContent || " " }),
2507
+ ];
2460
2508
  redrawTranscriptWithQueuedDisplays();
2461
2509
  return true;
2462
2510
  }
@@ -2490,7 +2538,7 @@ function OpenTuiApp(props) {
2490
2538
  }
2491
2539
  function requeueRejectedSteer(input, displayId) {
2492
2540
  const queuedDisplayId = displayId ?? addQueuedUserDisplay(input);
2493
- updateQueuedUserDisplay(queuedDisplayId, true);
2541
+ updateUserInputDisplayStatus(queuedDisplayId, "queued");
2494
2542
  rejectedSteerInputs.push({ input, displayId: queuedDisplayId });
2495
2543
  syncQueuedComposerInputCount();
2496
2544
  if (!isRunning())
@@ -2583,9 +2631,12 @@ function OpenTuiApp(props) {
2583
2631
  queueComposerInput(input, { showInTranscript: true });
2584
2632
  return;
2585
2633
  }
2586
- const displayId = addQueuedUserDisplay(input);
2587
- const pendingInput = run.inputController.enqueue(input);
2588
- pendingSteerInputs.push({ id: pendingInput.id, input, displayId });
2634
+ // Expand here because steer inputs bypass handleInput; keep the expanded
2635
+ // text in the record so a rejected steer requeues without stale markers.
2636
+ const expandedInput = expandComposerPastedTexts(input);
2637
+ const displayId = addPendingSteerUserDisplay(expandedInput);
2638
+ const pendingInput = run.inputController.enqueue(expandedInput);
2639
+ pendingSteerInputs.push({ id: pendingInput.id, input: expandedInput, displayId });
2589
2640
  syncPendingSteerInputCount();
2590
2641
  setNotice("Steer pending for next model call");
2591
2642
  }
@@ -2883,12 +2934,17 @@ function OpenTuiApp(props) {
2883
2934
  updateTranscriptHost(transcriptHost, transcriptState, messages, transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
2884
2935
  syncPromptSurfaces();
2885
2936
  }
2886
- function redrawTranscript(extra, baseMessages = displayMessages) {
2937
+ function redrawTranscript(extra, baseMessages = displayMessages, options = {}) {
2887
2938
  streamingDisplay = extra;
2888
- renderTranscriptNow(streamingDisplay, baseMessages);
2939
+ renderTranscriptNow(streamingDisplay, baseMessages, options);
2889
2940
  }
2890
- function renderTranscriptNow(extra, baseMessages = displayMessages) {
2891
- const shouldFollow = shouldFollowTranscriptBeforeUpdate();
2941
+ function renderTranscriptNow(extra, baseMessages = displayMessages, options = {}) {
2942
+ const shouldFollow = options.forceFollow ? true : shouldFollowTranscriptBeforeUpdate();
2943
+ if (options.forceFollow) {
2944
+ transcriptScrollFollowing = true;
2945
+ transcriptScrollInitialized = true;
2946
+ transcriptForceScrollPending = true;
2947
+ }
2892
2948
  const nextMessages = compactDisplayMessages([
2893
2949
  ...baseMessages,
2894
2950
  ...(extra ? [extra] : []),
@@ -2906,6 +2962,7 @@ function OpenTuiApp(props) {
2906
2962
  syncSidebarChrome();
2907
2963
  redrawQuestionPanel();
2908
2964
  redrawStatsPanel();
2965
+ redrawProviderDialog();
2909
2966
  redrawFeishuSetupPanel();
2910
2967
  scrollbox?.requestRender();
2911
2968
  scheduleTranscriptScrollAfterUpdate(shouldFollow);
@@ -2980,7 +3037,12 @@ function OpenTuiApp(props) {
2980
3037
  step,
2981
3038
  providerId,
2982
3039
  query: "",
2983
- index: step === "models" ? preferredPickerIndex("model", items) : 0,
3040
+ index: step === "models"
3041
+ ? preferredPickerIndex("model", items)
3042
+ // "(current)" sits at the bottom of the rewind list and is the safe default.
3043
+ : step === "rewind"
3044
+ ? Math.max(0, items.length - 1)
3045
+ : 0,
2984
3046
  apiKey: "",
2985
3047
  };
2986
3048
  activePrompt()?.clear();
@@ -3011,6 +3073,10 @@ function OpenTuiApp(props) {
3011
3073
  return providerId ? buildPickerItems("provider-auth", providerId) : [];
3012
3074
  if (step === "skills")
3013
3075
  return buildSkillItems();
3076
+ if (step === "rewind")
3077
+ return buildRewindPickerItems();
3078
+ if (step === "rewind-action")
3079
+ return buildRewindActionItems(providerId);
3014
3080
  if (step === "models") {
3015
3081
  if (providerDialogModelItems?.key === modelPickerCacheKey(providerId)) {
3016
3082
  return providerDialogModelItems.items;
@@ -3131,11 +3197,13 @@ function OpenTuiApp(props) {
3131
3197
  providerDialogRoot.requestRender();
3132
3198
  return;
3133
3199
  }
3134
- const width = Math.max(56, Math.min(76, dimensions().width - 4));
3200
+ const terminal = liveTerminalDimensions();
3201
+ const width = providerDialogPanelWidth(terminal.width);
3135
3202
  const height = PROVIDER_DIALOG_ROWS + 7;
3203
+ const columnWidths = providerDialogColumnWidths(state, width);
3136
3204
  providerDialogRoot.visible = true;
3137
- providerDialogRoot.width = dimensions().width;
3138
- providerDialogRoot.height = dimensions().height;
3205
+ providerDialogRoot.width = terminal.width;
3206
+ providerDialogRoot.height = terminal.height;
3139
3207
  providerDialogRoot.left = 0;
3140
3208
  providerDialogRoot.top = 0;
3141
3209
  providerDialogRoot.backgroundColor = modalBackdropColor();
@@ -3143,8 +3211,8 @@ function OpenTuiApp(props) {
3143
3211
  providerDialogPanel.visible = true;
3144
3212
  providerDialogPanel.width = width;
3145
3213
  providerDialogPanel.height = height;
3146
- providerDialogPanel.left = Math.max(0, Math.floor((dimensions().width - width) / 2));
3147
- providerDialogPanel.top = Math.max(0, Math.floor(dimensions().height / 4));
3214
+ providerDialogPanel.left = Math.max(0, Math.floor((terminal.width - width) / 2));
3215
+ providerDialogPanel.top = Math.max(0, Math.floor(terminal.height / 4));
3148
3216
  providerDialogPanel.backgroundColor = theme.backgroundPanel;
3149
3217
  providerDialogPanel.borderColor = theme.backgroundPanel;
3150
3218
  providerDialogPanel.requestRender();
@@ -3218,20 +3286,20 @@ function OpenTuiApp(props) {
3218
3286
  gutter.fg = active ? activeText : providerDialogGutterColor(row.item.gutter ?? (isCurrentModelItem(row.item) ? "●" : undefined));
3219
3287
  }
3220
3288
  if (label) {
3221
- label.content = truncate(row.item.label, providerDialogLabelWidth(state));
3289
+ label.content = truncate(row.item.label, columnWidths.label);
3222
3290
  label.fg = active ? activeText : isCurrentModelItem(row.item) ? theme.primary : theme.text;
3223
3291
  }
3224
3292
  if (detail) {
3225
3293
  const detailText = state.query.trim() && state.step === "models"
3226
3294
  ? row.item.category ?? row.item.detail ?? ""
3227
3295
  : row.item.detail ?? "";
3228
- detail.width = providerDialogDetailWidth(state);
3229
- detail.content = truncate(detailText, providerDialogDetailWidth(state));
3296
+ detail.width = columnWidths.detail;
3297
+ detail.content = truncate(detailText, columnWidths.detail);
3230
3298
  detail.fg = active ? activeText : theme.textMuted;
3231
3299
  }
3232
3300
  if (footer) {
3233
- footer.width = providerDialogFooterWidth(state);
3234
- footer.content = row.item.footer ?? "";
3301
+ footer.width = columnWidths.footer;
3302
+ footer.content = truncate(row.item.footer ?? "", columnWidths.footer);
3235
3303
  footer.fg = active ? activeText : theme.textMuted;
3236
3304
  }
3237
3305
  }
@@ -3247,6 +3315,10 @@ function OpenTuiApp(props) {
3247
3315
  return "Connect a provider";
3248
3316
  if (state.step === "skills")
3249
3317
  return "Select skill";
3318
+ if (state.step === "rewind")
3319
+ return "Rewind — restore to the point before…";
3320
+ if (state.step === "rewind-action")
3321
+ return "Rewind — what to restore?";
3250
3322
  const provider = providerDisplayName(state.providerId);
3251
3323
  if (state.step === "auth")
3252
3324
  return `${provider} auth method`;
@@ -3267,6 +3339,10 @@ function OpenTuiApp(props) {
3267
3339
  }
3268
3340
  if (state.step === "skills")
3269
3341
  return `↑/↓ move · enter insert · esc close${count}`;
3342
+ if (state.step === "rewind")
3343
+ return `↑/↓ move · enter continue · esc cancel${count}`;
3344
+ if (state.step === "rewind-action")
3345
+ return "↑/↓ move · enter confirm · esc back";
3270
3346
  const escLabel = state.step === "providers" ? "esc close" : "esc back";
3271
3347
  return `↑/↓ move · enter select · ${escLabel}${count}`;
3272
3348
  }
@@ -3279,14 +3355,25 @@ function OpenTuiApp(props) {
3279
3355
  return theme.warning;
3280
3356
  return theme.textMuted;
3281
3357
  }
3282
- function providerDialogLabelWidth(state) {
3283
- return state.step === "skills" ? 22 : 37;
3284
- }
3285
- function providerDialogDetailWidth(state) {
3286
- return state.step === "skills" ? 26 : 16;
3287
- }
3288
- function providerDialogFooterWidth(state) {
3289
- return state.step === "skills" ? 9 : 8;
3358
+ function providerDialogPanelWidth(terminalWidth) {
3359
+ return Math.max(PROVIDER_DIALOG_MIN_WIDTH, Math.min(PROVIDER_DIALOG_MAX_WIDTH, terminalWidth - 4));
3360
+ }
3361
+ function providerDialogColumnWidths(state, panelWidth) {
3362
+ const contentWidth = Math.max(24, panelWidth - PROVIDER_DIALOG_ROW_RESERVED_WIDTH);
3363
+ const footer = state.step === "skills" ? 10 : state.step === "providers" ? 9 : 8;
3364
+ const minLabel = state.step === "skills" ? 18 : 24;
3365
+ const desiredDetail = state.step === "skills"
3366
+ ? 30
3367
+ : state.step === "providers"
3368
+ ? 24
3369
+ : state.step === "rewind-action"
3370
+ ? 40
3371
+ : state.step === "rewind"
3372
+ ? 18
3373
+ : 16;
3374
+ const detail = Math.max(8, Math.min(desiredDetail, contentWidth - footer - minLabel));
3375
+ const label = Math.max(8, contentWidth - detail - footer);
3376
+ return { label, detail, footer };
3290
3377
  }
3291
3378
  function isCurrentModelItem(item) {
3292
3379
  return item.value === props.agent.model || item.detail?.includes("current");
@@ -3351,8 +3438,8 @@ function OpenTuiApp(props) {
3351
3438
  else if (state.step === "key") {
3352
3439
  openProviderDialog(state.providerId && registry.supportsOAuth(state.providerId) ? "auth" : "providers", state.providerId);
3353
3440
  }
3354
- else if (state.step === "models" || state.step === "skills") {
3355
- closeProviderDialog();
3441
+ else if (state.step === "rewind-action") {
3442
+ openProviderDialog("rewind");
3356
3443
  }
3357
3444
  else {
3358
3445
  closeProviderDialog();
@@ -3483,6 +3570,20 @@ function OpenTuiApp(props) {
3483
3570
  await executeSlash(item.command);
3484
3571
  return;
3485
3572
  }
3573
+ if (state.step === "rewind") {
3574
+ if (!item.value) {
3575
+ // "(current)" — keep everything as is.
3576
+ closeProviderDialog();
3577
+ return;
3578
+ }
3579
+ openProviderDialog("rewind-action", item.value);
3580
+ return;
3581
+ }
3582
+ if (state.step === "rewind-action") {
3583
+ closeProviderDialog();
3584
+ await executeSlash(item.command);
3585
+ return;
3586
+ }
3486
3587
  if (state.step === "skills") {
3487
3588
  closeProviderDialog();
3488
3589
  insertSkillPrompt(item.value);
@@ -4597,6 +4698,129 @@ function OpenTuiApp(props) {
4597
4698
  applyingComposerImageReplacement = false;
4598
4699
  }
4599
4700
  }
4701
+ // Replaces pasted-text markers with their full content. Runs after @mention
4702
+ // expansion so mention-like tokens inside pasted content stay literal.
4703
+ // References stay registered for the whole session so prompt-history recall
4704
+ // and requeued drafts containing a marker expand again on resend.
4705
+ function expandComposerPastedTexts(text) {
4706
+ if (pendingPastedTexts.size === 0 || !text.includes("[Pasted text #"))
4707
+ return text;
4708
+ const references = [...pendingPastedTexts.entries()].map(([marker, content]) => ({ marker, content }));
4709
+ return expandPastedContentMarkers(text, references);
4710
+ }
4711
+ // Inserts [image#N] labels at the cursor, padding with spaces when the
4712
+ // paste lands glued to surrounding text. Returns false when no prompt is
4713
+ // mounted (the caller should leave the paste alone).
4714
+ function insertComposerImageLabels(event, labels) {
4715
+ const prompt = activePrompt();
4716
+ if (!prompt)
4717
+ return false;
4718
+ event.preventDefault?.();
4719
+ const current = prompt.plainText ?? "";
4720
+ const offset = Math.min(Math.max(prompt.cursorOffset ?? current.length, 0), current.length);
4721
+ const needsLead = offset > 0 && !/\s/.test(current[offset - 1] ?? "");
4722
+ const needsTrail = offset < current.length && !/\s/.test(current[offset] ?? "");
4723
+ const joined = labels.map((label) => `[${label}]`).join(" ");
4724
+ prompt.insertText(`${needsLead ? " " : ""}${joined}${needsTrail ? " " : ""}`);
4725
+ onPromptContentChange(readPromptText());
4726
+ return true;
4727
+ }
4728
+ function handleComposerPaste(event) {
4729
+ const text = typeof event.text === "string" ? event.text : decodePastedBytes(event.bytes);
4730
+ if (isImagePathPaste(text)) {
4731
+ // Insert the final [image#N] label at paste time and ingest the file in
4732
+ // the background. Inserting the raw path and swapping it later flashes
4733
+ // the path and resets the cursor (setPromptText jumps it to the end).
4734
+ const entries = splitPastedPaths(text).map((rawPath) => ({
4735
+ rawPath,
4736
+ label: imageLabelForPath(rawPath, nextImageAttachmentIndex),
4737
+ }));
4738
+ if (!insertComposerImageLabels(event, entries.map((entry) => entry.label)))
4739
+ return;
4740
+ nextImageAttachmentIndex += entries.length;
4741
+ trackImagePathIngestion(entries);
4742
+ return;
4743
+ }
4744
+ // Copying an image file in Finder pastes only the file's NAME; Cmd+V of
4745
+ // raw image data pastes nothing at all. Both leave the real bits on the
4746
+ // system clipboard, so attach from there.
4747
+ const bareName = bareImageFilenameFromPaste(text);
4748
+ if (bareName || !text.trim()) {
4749
+ const label = bareName
4750
+ ? imageLabelForPath(bareName, nextImageAttachmentIndex)
4751
+ : `image#${nextImageAttachmentIndex}.png`;
4752
+ if (!insertComposerImageLabels(event, [label]))
4753
+ return;
4754
+ nextImageAttachmentIndex += 1;
4755
+ trackClipboardImageIngestion(label, text);
4756
+ return;
4757
+ }
4758
+ if (!shouldCollapsePastedContent(text))
4759
+ return;
4760
+ event.preventDefault?.();
4761
+ const marker = createPastedContentMarker(text, nextPastedTextIndex);
4762
+ nextPastedTextIndex += 1;
4763
+ pendingPastedTexts.set(marker, text);
4764
+ const prompt = activePrompt();
4765
+ prompt?.insertText(marker);
4766
+ onPromptContentChange(readPromptText());
4767
+ }
4768
+ function trackImageIngestion(task) {
4769
+ pendingImageIngestions.add(task);
4770
+ void task.finally(() => pendingImageIngestions.delete(task));
4771
+ }
4772
+ function trackImagePathIngestion(entries) {
4773
+ trackImageIngestion((async () => {
4774
+ for (const { rawPath, label } of entries) {
4775
+ const result = await ingestImagePath(rawPath);
4776
+ if (result.attachment) {
4777
+ pendingImageAttachments.set(label, result.attachment);
4778
+ }
4779
+ else {
4780
+ addMessage("error", `Skipped image: ${rawPath}: ${result.error ?? "could not attach image"}`);
4781
+ replaceComposerImageLabel(label, "");
4782
+ }
4783
+ }
4784
+ })());
4785
+ }
4786
+ function trackClipboardImageIngestion(label, originalText) {
4787
+ trackImageIngestion((async () => {
4788
+ const result = await ingestClipboardImage();
4789
+ if (result.attachment) {
4790
+ pendingImageAttachments.set(label, result.attachment);
4791
+ return;
4792
+ }
4793
+ const restored = originalText.trim();
4794
+ // A filename-looking text paste with no image on the clipboard is just
4795
+ // text — restore it quietly. Only an empty paste (Cmd+V of image data)
4796
+ // warrants an error, since there is nothing to restore.
4797
+ if (!restored)
4798
+ addMessage("error", `Could not attach image from clipboard: ${result.error ?? "unknown error"}`);
4799
+ replaceComposerImageLabel(label, restored);
4800
+ })());
4801
+ }
4802
+ // Swaps a failed image label for its replacement (or drops it) without
4803
+ // moving the cursor relative to the surrounding text.
4804
+ function replaceComposerImageLabel(label, replacement) {
4805
+ const prompt = activePrompt();
4806
+ const current = prompt?.plainText ?? promptText;
4807
+ const token = `[${label}]`;
4808
+ const start = current.indexOf(token);
4809
+ if (start < 0)
4810
+ return;
4811
+ let end = start + token.length;
4812
+ if (!replacement && current[end] === " ")
4813
+ end += 1;
4814
+ const next = current.slice(0, start) + replacement + current.slice(end);
4815
+ if (prompt) {
4816
+ const cursor = Math.min(Math.max(prompt.cursorOffset ?? next.length, 0), current.length);
4817
+ prompt.setText(next);
4818
+ prompt.cursorOffset = cursor <= start
4819
+ ? cursor
4820
+ : Math.min(next.length, Math.max(start + replacement.length, cursor - (end - start) + replacement.length));
4821
+ }
4822
+ onPromptContentChange(next);
4823
+ }
4600
4824
  async function expandTextParts(parts) {
4601
4825
  const expandedParts = [];
4602
4826
  for (const part of parts) {
@@ -4607,14 +4831,18 @@ function OpenTuiApp(props) {
4607
4831
  const expansion = await expandAtMentions(part.text, props.args.cwd);
4608
4832
  if (expansion.missing.length)
4609
4833
  addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
4610
- for (const skipped of expansion.skipped)
4611
- addMessage("error", `Skipped @${skipped.path}: ${skipped.reason}`);
4612
- expandedParts.push({ type: "text", text: expansion.text });
4834
+ for (const skipped of expansion.skipped) {
4835
+ if (skipped.reason !== "too large")
4836
+ addMessage("error", `Skipped @${skipped.path}: ${skipped.reason}`);
4837
+ }
4838
+ expandedParts.push({ type: "text", text: expandComposerPastedTexts(expansion.text) });
4613
4839
  }
4614
4840
  return expandedParts;
4615
4841
  }
4616
4842
  async function handleInput(input, options = {}) {
4617
4843
  setNotice("");
4844
+ if (pendingImageIngestions.size > 0)
4845
+ await Promise.all([...pendingImageIngestions]);
4618
4846
  const labeledInput = buildImageContentPartsFromLabels(input, pendingImageAttachments);
4619
4847
  if (labeledInput.actualInput) {
4620
4848
  await runAgentInput(await expandTextParts(labeledInput.actualInput), labeledInput.displayInput, options);
@@ -4635,7 +4863,7 @@ function OpenTuiApp(props) {
4635
4863
  if (input.startsWith("/")) {
4636
4864
  const skillInvocation = parseSkillInvocation(input, skills);
4637
4865
  if (skillInvocation) {
4638
- await runAgentInput(skillInvocation.actualPrompt, input, options);
4866
+ await runAgentInput(expandComposerPastedTexts(skillInvocation.actualPrompt), input, options);
4639
4867
  return;
4640
4868
  }
4641
4869
  const handled = await executeSlash(input, options);
@@ -4645,9 +4873,11 @@ function OpenTuiApp(props) {
4645
4873
  const expansion = await expandAtMentions(input, props.args.cwd);
4646
4874
  if (expansion.missing.length)
4647
4875
  addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
4648
- for (const skipped of expansion.skipped)
4649
- addMessage("error", `Skipped @${skipped.path}: ${skipped.reason}`);
4650
- await runAgentInput(expansion.text, input, options);
4876
+ for (const skipped of expansion.skipped) {
4877
+ if (skipped.reason !== "too large")
4878
+ addMessage("error", `Skipped @${skipped.path}: ${skipped.reason}`);
4879
+ }
4880
+ await runAgentInput(expandComposerPastedTexts(expansion.text), input, options);
4651
4881
  }
4652
4882
  async function executeSlash(input, options = {}) {
4653
4883
  if (/^\/(?:thinking|toggle-thinking)(?:\s|$)/.test(input.trim())) {
@@ -4676,10 +4906,19 @@ function OpenTuiApp(props) {
4676
4906
  openPicker: (kind, providerId) => {
4677
4907
  void openPicker(kind, providerId);
4678
4908
  },
4909
+ openRewindPicker: () => {
4910
+ openProviderDialog("rewind");
4911
+ },
4912
+ fillComposer: (text) => {
4913
+ resetPromptHistoryBrowse();
4914
+ setPromptText(text);
4915
+ redrawDock();
4916
+ },
4679
4917
  registry,
4680
4918
  skillRegistry: skills,
4681
4919
  bashAllowlist: props.options.bashAllowlist,
4682
4920
  settingsManager: props.options.settingsManager,
4921
+ hookController: props.options.hookController,
4683
4922
  mcpManager: props.options.mcpManager,
4684
4923
  lspService,
4685
4924
  flushMemory: props.options.flushMemory,
@@ -4721,6 +4960,13 @@ function OpenTuiApp(props) {
4721
4960
  redrawTranscript(undefined, displayMessages);
4722
4961
  setTimeout(() => setNotice(""), 4000);
4723
4962
  }
4963
+ else if (result.startsWith("⏪")) {
4964
+ // /rewind truncated agent.messages — rebuild the transcript from the
4965
+ // rewound state before appending the summary.
4966
+ displayMessages = reconstructDisplayMessages(props.agent.messages);
4967
+ streamingDisplay = undefined;
4968
+ addMessage("assistant", result);
4969
+ }
4724
4970
  else {
4725
4971
  addMessage("assistant", result);
4726
4972
  }
@@ -4992,6 +5238,48 @@ function OpenTuiApp(props) {
4992
5238
  command: `/logout ${provider.id}`,
4993
5239
  }));
4994
5240
  }
5241
+ function buildRewindPickerItems() {
5242
+ const session = props.options.sessionManager;
5243
+ if (!session)
5244
+ return [];
5245
+ const checkpoints = session.getCheckpoints();
5246
+ const items = session.listUserTurns().map((turn, index) => {
5247
+ const files = checkpoints.filesTouchedAt(turn.id).length;
5248
+ return {
5249
+ label: turn.preview,
5250
+ detail: files > 0 ? `${files} file${files === 1 ? "" : "s"} changed` : "No code changes",
5251
+ value: String(index + 1),
5252
+ command: `/rewind ${index + 1}`,
5253
+ };
5254
+ });
5255
+ // Selecting "(current)" keeps everything as is — mirrors Claude Code.
5256
+ items.push({ label: "(current)", value: "", command: "" });
5257
+ return items;
5258
+ }
5259
+ function buildRewindActionItems(turnNumber) {
5260
+ if (!turnNumber)
5261
+ return [];
5262
+ return [
5263
+ {
5264
+ label: "Restore conversation and code",
5265
+ detail: "Rewind the chat and undo tracked file edits",
5266
+ value: turnNumber,
5267
+ command: `/rewind ${turnNumber}`,
5268
+ },
5269
+ {
5270
+ label: "Restore conversation only",
5271
+ detail: "Keep file changes on disk",
5272
+ value: turnNumber,
5273
+ command: `/rewind ${turnNumber} --chat`,
5274
+ },
5275
+ {
5276
+ label: "Restore code only",
5277
+ detail: "Undo tracked file edits, keep the conversation",
5278
+ value: turnNumber,
5279
+ command: `/rewind ${turnNumber} --code`,
5280
+ },
5281
+ ];
5282
+ }
4995
5283
  function buildSkillItems() {
4996
5284
  return skills.summaries().map((skill) => {
4997
5285
  const tags = skill.tags && skill.tags.length > 0 ? ` · ${skill.tags.join(", ")}` : "";
@@ -5106,14 +5394,19 @@ function OpenTuiApp(props) {
5106
5394
  return;
5107
5395
  }
5108
5396
  rememberPromptHistory(displayInput);
5109
- const reusedQueuedDisplay = promoteQueuedUserDisplay(options.displayId, displayInput);
5397
+ // History keeps the short marker (it expands again on resend); the
5398
+ // transcript shows the full pasted content once the message is sent.
5399
+ const displayContent = expandComposerPastedTexts(displayInput);
5400
+ const reusedQueuedDisplay = promoteQueuedUserDisplay(options.displayId, displayContent);
5110
5401
  const nextMessages = reusedQueuedDisplay
5111
5402
  ? displayMessages
5112
- : [...displayMessages, { role: "user", content: displayInput }];
5403
+ : [...displayMessages, { role: "user", content: displayContent }];
5113
5404
  if (!reusedQueuedDisplay)
5114
5405
  displayMessages = nextMessages;
5115
5406
  streamingDisplay = undefined;
5116
- redrawTranscript(undefined, nextMessages);
5407
+ // The user just sent this message — re-engage bottom-follow so the new
5408
+ // turn is visible even if they had scrolled up to read earlier history.
5409
+ redrawTranscript(undefined, nextMessages, { forceFollow: true });
5117
5410
  const taskStartedAt = Date.now();
5118
5411
  const run = beginAgentRun();
5119
5412
  traceEvent("tui_agent_run_begin", {
@@ -5127,6 +5420,8 @@ function OpenTuiApp(props) {
5127
5420
  }, { surface: "tui" });
5128
5421
  let assistantContent = "";
5129
5422
  let assistantReasoning = "";
5423
+ let textDisplaySanitizer = createStreamingInternalReminderSanitizer();
5424
+ let reasoningDisplaySanitizer = createStreamingInternalReminderSanitizer();
5130
5425
  const toolCalls = [];
5131
5426
  const assistantParts = [];
5132
5427
  let turnStartedAt;
@@ -5141,7 +5436,7 @@ function OpenTuiApp(props) {
5141
5436
  const buildStreamingDisplay = (status) => {
5142
5437
  const currentParts = snapshotDisplayParts(assistantParts);
5143
5438
  const partContent = assistantContent || contentFromParts(currentParts);
5144
- return {
5439
+ return sanitizeDisplayMessage({
5145
5440
  role: "assistant",
5146
5441
  content: partContent,
5147
5442
  reasoning: assistantReasoning || undefined,
@@ -5150,7 +5445,7 @@ function OpenTuiApp(props) {
5150
5445
  status,
5151
5446
  streaming: true,
5152
5447
  turnStartedAt,
5153
- };
5448
+ });
5154
5449
  };
5155
5450
  const flushStreamingRedraw = () => {
5156
5451
  if (pendingStreamingRedrawTimer === undefined)
@@ -5180,6 +5475,8 @@ function OpenTuiApp(props) {
5180
5475
  if (event.type === "turn_start") {
5181
5476
  assistantContent = "";
5182
5477
  assistantReasoning = "";
5478
+ textDisplaySanitizer = createStreamingInternalReminderSanitizer();
5479
+ reasoningDisplaySanitizer = createStreamingInternalReminderSanitizer();
5183
5480
  toolCalls.length = 0;
5184
5481
  assistantParts.length = 0;
5185
5482
  turnStartedAt = Date.now();
@@ -5192,22 +5489,42 @@ function OpenTuiApp(props) {
5192
5489
  });
5193
5490
  }
5194
5491
  else if (event.type === "text_delta") {
5195
- assistantContent += event.content;
5196
- appendTextPart(assistantParts, event.content);
5197
- scheduleStreamingRedraw();
5492
+ const content = textDisplaySanitizer.push(event.content);
5493
+ if (content) {
5494
+ assistantContent += content;
5495
+ appendTextPart(assistantParts, content);
5496
+ scheduleStreamingRedraw();
5497
+ }
5198
5498
  }
5199
5499
  else if (event.type === "reasoning_delta") {
5500
+ const content = reasoningDisplaySanitizer.push(event.content);
5501
+ if (!content)
5502
+ continue;
5200
5503
  debugReasoningStream({
5201
5504
  stage: "ui_append",
5202
5505
  providerId: props.agent.providerId,
5203
5506
  modelId: props.agent.apiModel,
5204
5507
  beforeLength: assistantReasoning.length,
5205
- delta: summarizeDebugText(event.content),
5206
- afterLength: assistantReasoning.length + event.content.length,
5508
+ delta: summarizeDebugText(content),
5509
+ afterLength: assistantReasoning.length + content.length,
5207
5510
  });
5208
- assistantReasoning += event.content;
5511
+ assistantReasoning += content;
5209
5512
  scheduleStreamingRedraw();
5210
5513
  }
5514
+ else if (event.type === "hook_start") {
5515
+ setNotice(`Hook ${event.eventName}: ${event.hookId}`);
5516
+ }
5517
+ else if (event.type === "hook_end") {
5518
+ if (event.decision === "deny") {
5519
+ setNotice(event.reason ?? `Hook ${event.hookId} denied ${event.eventName}`);
5520
+ }
5521
+ }
5522
+ else if (event.type === "hook_error") {
5523
+ setNotice(`Hook ${event.hookId} error: ${event.error}`);
5524
+ }
5525
+ else if (event.type === "provider_retry") {
5526
+ setNotice(`Connection interrupted — retrying (${event.attempt}/${event.maxAttempts})…`);
5527
+ }
5211
5528
  else if (event.type === "tool_call_start") {
5212
5529
  // Insert a streaming placeholder so the user sees feedback the moment
5213
5530
  // the model commits to a tool call, instead of waiting for the args
@@ -5336,6 +5653,15 @@ function OpenTuiApp(props) {
5336
5653
  clearTimeout(pendingStreamingRedrawTimer);
5337
5654
  pendingStreamingRedrawTimer = undefined;
5338
5655
  }
5656
+ const flushedText = textDisplaySanitizer.flush();
5657
+ if (flushedText) {
5658
+ assistantContent += flushedText;
5659
+ appendTextPart(assistantParts, flushedText);
5660
+ }
5661
+ const flushedReasoning = reasoningDisplaySanitizer.flush();
5662
+ if (flushedReasoning) {
5663
+ assistantReasoning += flushedReasoning;
5664
+ }
5339
5665
  if (event.usage) {
5340
5666
  setSidebarUsage((current) => ({
5341
5667
  contextTokens: event.usage.promptTokens || current.contextTokens,
@@ -5351,20 +5677,21 @@ function OpenTuiApp(props) {
5351
5677
  }
5352
5678
  bumpSidebar();
5353
5679
  const currentParts = snapshotDisplayParts(assistantParts);
5354
- const finalContent = assistantContent || contentFromParts(currentParts);
5680
+ const finalContent = sanitizeInternalReminderBlocks(assistantContent || contentFromParts(currentParts));
5681
+ const finalReasoning = sanitizeInternalReasoningText(assistantReasoning);
5355
5682
  const finalToolCalls = toolCalls.length > 0
5356
5683
  ? [...toolCalls]
5357
5684
  : toolCallsFromParts(currentParts);
5358
- const assistantMessage = {
5685
+ const assistantMessage = sanitizeDisplayMessage({
5359
5686
  role: "assistant",
5360
5687
  content: finalContent,
5361
- reasoning: assistantReasoning || undefined,
5688
+ reasoning: finalReasoning || undefined,
5362
5689
  toolCalls: finalToolCalls.length ? finalToolCalls : undefined,
5363
5690
  parts: currentParts.length ? currentParts : undefined,
5364
5691
  turnStartedAt,
5365
5692
  turnCompletedAt: Date.now(),
5366
5693
  turnUsage: event.usage,
5367
- };
5694
+ });
5368
5695
  const nextMessages = hasRenderableMessage(assistantMessage)
5369
5696
  ? [...displayMessages, assistantMessage]
5370
5697
  : displayMessages;
@@ -5464,16 +5791,16 @@ function OpenTuiApp(props) {
5464
5791
  return h("box", {
5465
5792
  ref: (ref) => {
5466
5793
  sessionComposerShell = ref;
5467
- ref.visible = !isHomeSurfaceActive(streamingDisplay) && !pendingQuestion() && !pendingFeedback() && !statsPanel && !pendingFeishuSetup();
5794
+ ref.visible = !isComposerHiddenByModal();
5468
5795
  },
5469
5796
  width: "100%",
5470
5797
  paddingLeft: 2,
5471
5798
  paddingRight: 2,
5472
5799
  flexShrink: 0,
5473
- visible: !isHomeSurfaceActive(streamingDisplay) && !pendingQuestion() && !statsPanel && !pendingFeishuSetup(),
5800
+ visible: !isComposerHiddenByModal(),
5474
5801
  }, renderPrompt({
5475
5802
  ref: (ref) => { sessionPromptRef = ref; },
5476
- focused: !isHomeSurfaceActive(streamingDisplay),
5803
+ focused: !isComposerHiddenByModal(),
5477
5804
  onSubmit: submitPrompt,
5478
5805
  isFallbackNewlineKey: isTrackedShiftReturn,
5479
5806
  onFallbackNewline: () => canInsertPromptNewline() && (activePrompt()?.newLine() ?? false),
@@ -5488,6 +5815,7 @@ function OpenTuiApp(props) {
5488
5815
  model: promptModelTitle,
5489
5816
  interruptHint: promptStatusText,
5490
5817
  tabHint: () => isRunning() ? "queue" : "mode",
5818
+ onPaste: handleComposerPaste,
5491
5819
  placeholder: () => {
5492
5820
  const approvalState = pendingApproval();
5493
5821
  if (approvalState)
@@ -5508,7 +5836,6 @@ function OpenTuiApp(props) {
5508
5836
  }));
5509
5837
  }
5510
5838
  function renderHomeSurface() {
5511
- const homeHeight = Math.max(16, dimensions().height - 4);
5512
5839
  const logoLines = bubbleWordmarkForWidth(dimensions().width);
5513
5840
  return h("box", {
5514
5841
  ref: (ref) => {
@@ -5516,7 +5843,8 @@ function OpenTuiApp(props) {
5516
5843
  ref.visible = isHomeSurfaceActive(streamingDisplay);
5517
5844
  },
5518
5845
  visible: isHomeSurfaceActive(streamingDisplay),
5519
- height: homeHeight,
5846
+ height: "100%",
5847
+ minHeight: 0,
5520
5848
  flexDirection: "column",
5521
5849
  alignItems: "center",
5522
5850
  justifyContent: "center",
@@ -5528,57 +5856,6 @@ function OpenTuiApp(props) {
5528
5856
  ...(props.options.updateNotice
5529
5857
  ? [h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, h("text", { fg: theme.accent, content: props.options.updateNotice }))]
5530
5858
  : []),
5531
- h("box", { height: 1, minHeight: 0, flexShrink: 1 }),
5532
- h("box", {
5533
- ref: (ref) => {
5534
- homeComposerShell = ref;
5535
- ref.visible = isHomeSurfaceActive(streamingDisplay) && !pendingQuestion() && !pendingFeedback() && !statsPanel && !pendingFeishuSetup();
5536
- },
5537
- width: "100%",
5538
- maxWidth: 75,
5539
- zIndex: 1000,
5540
- paddingTop: 1,
5541
- flexShrink: 0,
5542
- visible: isHomeSurfaceActive(streamingDisplay) && !pendingQuestion() && !statsPanel && !pendingFeishuSetup(),
5543
- }, renderPrompt({
5544
- ref: (ref) => {
5545
- homePromptRef = ref;
5546
- if (isHomeSurfaceActive(streamingDisplay))
5547
- setTimeout(() => ref.focus(), 0);
5548
- },
5549
- focused: isHomeSurfaceActive(streamingDisplay),
5550
- onSubmit: submitPrompt,
5551
- isFallbackNewlineKey: isTrackedShiftReturn,
5552
- onFallbackNewline: () => canInsertPromptNewline() && (activePrompt()?.newLine() ?? false),
5553
- onContentChange: onPromptContentChange,
5554
- onKeyDown: handlePickerKey,
5555
- onUiKeyDown: promptUiKeyDown,
5556
- getText: readPromptText,
5557
- disabled: () => !!pendingFeedback() || !!statsPanel,
5558
- mode,
5559
- registerModeLabel: registerPromptModeLabel,
5560
- registerModelLabel: registerPromptModelLabel,
5561
- model: promptModelTitle,
5562
- interruptHint: promptStatusText,
5563
- tabHint: () => isRunning() ? "queue" : "mode",
5564
- placeholder: () => {
5565
- const approvalState = pendingApproval();
5566
- if (approvalState)
5567
- return "Press Enter to approve or Esc to reject";
5568
- if (pendingQuestion())
5569
- return "Answer the question below";
5570
- if (pendingFeedback())
5571
- return "Describe feedback below";
5572
- if (statsPanel)
5573
- return "Stats panel is open";
5574
- const plan = pendingPlan();
5575
- if (plan)
5576
- return "Press Enter to approve plan or Esc to reject";
5577
- if (isRunning())
5578
- return "Steer current run...";
5579
- return `Ask anything... "${homePrompt}"`;
5580
- },
5581
- })),
5582
5859
  ]);
5583
5860
  }
5584
5861
  function renderQuestionPanelHost() {
@@ -5656,7 +5933,10 @@ function OpenTuiApp(props) {
5656
5933
  visible: false,
5657
5934
  flexShrink: 0,
5658
5935
  }, h("textarea", {
5659
- ref: (ref) => { questionCustomInput = ref; },
5936
+ ref: (ref) => {
5937
+ preserveCursorOnMouseSelection(ref);
5938
+ questionCustomInput = ref;
5939
+ },
5660
5940
  placeholder: "Type your own answer",
5661
5941
  placeholderColor: theme.textMuted,
5662
5942
  textColor: theme.text,
@@ -5745,7 +6025,10 @@ function OpenTuiApp(props) {
5745
6025
  wrapMode: "word",
5746
6026
  content: "Creates a public GitHub issue at DylanDDeng/bubble. Review before sending.",
5747
6027
  }), h("textarea", {
5748
- ref: (ref) => { feedbackInput = ref; },
6028
+ ref: (ref) => {
6029
+ preserveCursorOnMouseSelection(ref);
6030
+ feedbackInput = ref;
6031
+ },
5749
6032
  placeholder: "Describe what happened",
5750
6033
  placeholderColor: theme.textMuted,
5751
6034
  textColor: theme.text,
@@ -6657,11 +6940,9 @@ function OpenTuiApp(props) {
6657
6940
  visible: !!approval,
6658
6941
  focusable: true,
6659
6942
  onKeyDown: handleApprovalKey,
6660
- position: "absolute",
6661
- left: 2,
6662
- right: 2,
6663
- bottom: 4,
6664
- zIndex: 200,
6943
+ width: "100%",
6944
+ flexShrink: 0,
6945
+ marginTop: 1,
6665
6946
  backgroundColor: theme.backgroundPanel,
6666
6947
  border: ["left"],
6667
6948
  borderColor: theme.warning,
@@ -6825,12 +7106,8 @@ function OpenTuiApp(props) {
6825
7106
  ]),
6826
7107
  renderFooter({
6827
7108
  cwd: props.args.cwd,
6828
- mode,
6829
7109
  running: isRunning,
6830
7110
  registerScanner: registerPromptScanner,
6831
- registerModeBadge: registerFooterModeBadge,
6832
- traceVerbose: verboseTrace,
6833
- registerTraceBadge: registerFooterTraceBadge,
6834
7111
  }),
6835
7112
  renderProviderDialog(),
6836
7113
  renderStatsPanel(),
@@ -6841,7 +7118,10 @@ function OpenTuiApp(props) {
6841
7118
  function renderPrompt(input) {
6842
7119
  const transparentBackground = "#00000000";
6843
7120
  return h("box", { flexDirection: "column", flexShrink: 0, marginTop: 1 }, h("box", { width: "100%", border: true, borderColor: theme.border, backgroundColor: transparentBackground }, h("box", { flexDirection: "column", paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1, backgroundColor: transparentBackground }, h("textarea", {
6844
- ref: input.ref,
7121
+ ref: (ref) => {
7122
+ preserveCursorOnMouseSelection(ref);
7123
+ input.ref(ref);
7124
+ },
6845
7125
  focused: input.focused,
6846
7126
  placeholder: input.placeholder(),
6847
7127
  placeholderColor: theme.textMuted,
@@ -6849,8 +7129,10 @@ function renderPrompt(input) {
6849
7129
  focusedTextColor: theme.text,
6850
7130
  backgroundColor: transparentBackground,
6851
7131
  focusedBackgroundColor: transparentBackground,
7132
+ cursorColor: theme.primary,
6852
7133
  minHeight: 1,
6853
7134
  maxHeight: 6,
7135
+ ...(input.onPaste ? { onPaste: input.onPaste } : {}),
6854
7136
  onContentChange: () => input.onContentChange(input.getText()),
6855
7137
  keyBindings: PROMPT_TEXTAREA_KEYBINDINGS,
6856
7138
  onKeyDown: (event) => {
@@ -6965,8 +7247,9 @@ function renderUserMessage(message, index) {
6965
7247
  const userChildren = [
6966
7248
  h("text", { fg: theme.messageUserText, wrapMode: "word" }, message.content || " "),
6967
7249
  ];
6968
- if (message.queued) {
6969
- userChildren.push(h("box", { paddingTop: 1 }, h("text", { fg: theme.textMuted }, h("span", { bg: theme.primary, fg: theme.background, bold: true }, " QUEUED "))));
7250
+ const inputBadge = userInputStatusBadgeLabel(message.inputStatus);
7251
+ if (inputBadge) {
7252
+ userChildren.push(h("box", { paddingTop: 1 }, h("text", { fg: theme.textMuted }, h("span", { bg: theme.primary, fg: theme.background, bold: true }, ` ${inputBadge} `))));
6970
7253
  }
6971
7254
  return h("box", {
6972
7255
  border: ["left"],
@@ -6977,17 +7260,20 @@ function renderUserMessage(message, index) {
6977
7260
  }, h("box", { paddingTop: 1, paddingBottom: 1, paddingLeft: 2, backgroundColor: theme.backgroundPanel, flexShrink: 0, flexDirection: "column" }, ...userChildren));
6978
7261
  }
6979
7262
  function renderAssistantMessage(message, syntaxStyle, subtleSyntaxStyle, showThinking = true, verboseTrace = false, width = 80) {
7263
+ message = sanitizeDisplayMessage(message);
6980
7264
  const visibleReasoning = showThinking
6981
- ? sanitizeInternalReminderBlocks(message.reasoning ?? "").trim()
7265
+ ? sanitizeInternalReasoningText(message.reasoning ?? "").trim()
6982
7266
  : "";
6983
- const modelSwitch = parseModelSwitchMessage(message.content);
7267
+ const sanitizedContent = sanitizeInternalReminderBlocks(message.content);
7268
+ const modelSwitch = parseModelSwitchMessage(sanitizedContent);
6984
7269
  if (modelSwitch && !visibleReasoning && !(message.toolCalls?.length)) {
6985
7270
  return renderModelSwitchMessage(modelSwitch);
6986
7271
  }
6987
7272
  const children = [];
6988
7273
  const parts = message.parts ?? [];
6989
7274
  const hasParts = parts.length > 0;
6990
- if (message.status && !visibleReasoning && !message.content.trim() && !(message.toolCalls?.length) && !hasParts) {
7275
+ const trimmedContent = sanitizedContent.trim();
7276
+ if (message.status && !visibleReasoning && !trimmedContent && !(message.toolCalls?.length) && !hasParts) {
6991
7277
  children.push(h("box", { paddingLeft: 3, marginTop: 1, flexShrink: 0 }, h("text", { fg: theme.messageThinkingText }, assistantStatusLabel(message))));
6992
7278
  }
6993
7279
  if (visibleReasoning) {
@@ -7003,7 +7289,6 @@ function renderAssistantMessage(message, syntaxStyle, subtleSyntaxStyle, showThi
7003
7289
  fg: theme.messageThinkingContentText,
7004
7290
  })));
7005
7291
  }
7006
- const trimmedContent = message.content.trim();
7007
7292
  if (hasParts) {
7008
7293
  renderAssistantMessageParts(children, parts, syntaxStyle, verboseTrace, width, message.streaming === true);
7009
7294
  }
@@ -7041,7 +7326,7 @@ function renderAssistantMessage(message, syntaxStyle, subtleSyntaxStyle, showThi
7041
7326
  function renderAssistantMessageParts(children, parts, syntaxStyle, verboseTrace, width, streaming) {
7042
7327
  for (const part of parts) {
7043
7328
  if (part.type === "text") {
7044
- const content = part.content.trim();
7329
+ const content = sanitizeInternalReminderBlocks(part.content).trim();
7045
7330
  if (!content)
7046
7331
  continue;
7047
7332
  children.push(h("box", {
@@ -7067,7 +7352,7 @@ function renderAssistantMessageParts(children, parts, syntaxStyle, verboseTrace,
7067
7352
  }
7068
7353
  function lastPartHasText(parts) {
7069
7354
  const last = parts[parts.length - 1];
7070
- return last?.type === "text" && !!last.content.trim();
7355
+ return last?.type === "text" && !!sanitizeInternalReminderBlocks(last.content).trim();
7071
7356
  }
7072
7357
  function parseModelSwitchMessage(content) {
7073
7358
  const match = content.trim().match(/^Model switched to (.+)\.$/);
@@ -7121,7 +7406,7 @@ function renderMarkdownContent(content, syntaxStyle, options) {
7121
7406
  function updateTranscriptHost(host, state, messages, options, syntaxStyle, subtleSyntaxStyle) {
7122
7407
  const showThinking = options?.showThinking ?? true;
7123
7408
  const verboseTrace = options?.verboseTrace ?? false;
7124
- const visibleMessages = messages.filter((message) => hasRenderableMessage(message, showThinking));
7409
+ const visibleMessages = sanitizeDisplayMessages(messages).filter((message) => hasRenderableMessage(message, showThinking));
7125
7410
  const ctx = host.ctx;
7126
7411
  const nextEntries = [];
7127
7412
  if (!visibleMessages.length && !options?.plan) {
@@ -7200,6 +7485,7 @@ function transcriptMessageKey(message, index) {
7200
7485
  return `${index}:${message.role}`;
7201
7486
  }
7202
7487
  function transcriptMessageSignature(message, compactionExpanded = false) {
7488
+ message = sanitizeDisplayMessage(message);
7203
7489
  if (message.role !== "assistant")
7204
7490
  return message.role;
7205
7491
  if (message.syntheticKind === "ui_compact_card") {
@@ -7217,12 +7503,13 @@ function transcriptMessageSignature(message, compactionExpanded = false) {
7217
7503
  }
7218
7504
  function updateMessageEntry(entry, message, showThinking = true, compactionExpanded = false, assistantOptions) {
7219
7505
  if (message.role === "user") {
7506
+ const inputBadge = userInputStatusBadgeLabel(message.inputStatus);
7220
7507
  if (entry.refs.userText)
7221
7508
  entry.refs.userText.content = message.content || " ";
7222
7509
  if (entry.refs.userQueuedBox)
7223
- entry.refs.userQueuedBox.visible = message.queued === true;
7510
+ entry.refs.userQueuedBox.visible = !!inputBadge;
7224
7511
  if (entry.refs.userQueuedText)
7225
- entry.refs.userQueuedText.content = message.queued ? " QUEUED " : "";
7512
+ entry.refs.userQueuedText.content = inputBadge ? ` ${inputBadge} ` : "";
7226
7513
  return;
7227
7514
  }
7228
7515
  if (message.role === "error") {
@@ -7249,6 +7536,7 @@ function updateMessageEntry(entry, message, showThinking = true, compactionExpan
7249
7536
  }
7250
7537
  }
7251
7538
  function updateAssistantEntry(entry, message, showThinking, options) {
7539
+ message = sanitizeDisplayMessage(message);
7252
7540
  const content = message.content.trim();
7253
7541
  const visibleReasoning = showThinking ? message.reasoning?.trim() ?? "" : "";
7254
7542
  const tools = message.toolCalls ?? [];
@@ -7345,7 +7633,7 @@ function updateAssistantPartEntries(entry, parts, options, streaming) {
7345
7633
  const key = `part:${index}:${part.type}`;
7346
7634
  const previous = previousEntries.get(key);
7347
7635
  if (part.type === "text") {
7348
- const content = part.content.trim();
7636
+ const content = sanitizeInternalReminderBlocks(part.content).trim();
7349
7637
  let ref;
7350
7638
  if (previous?.kind === "text") {
7351
7639
  ref = previous;
@@ -7467,6 +7755,25 @@ function createTraceGroupRenderable(ctx, group, syntaxStyle, width = 80) {
7467
7755
  const children = [
7468
7756
  createText(ctx, traceGroupHeaderStyledText(group, width), { wrapMode: "none" }),
7469
7757
  ];
7758
+ const commandBlock = executeCommandBlockFor(group, width);
7759
+ if (commandBlock) {
7760
+ children.push(createBox(ctx, {
7761
+ paddingLeft: 2,
7762
+ flexDirection: "column",
7763
+ flexShrink: 0,
7764
+ }, [
7765
+ ...commandBlock.lines.map((line, index) => createText(ctx, `${index === 0 ? "$ " : " "}${line}`, {
7766
+ fg: theme.toolText,
7767
+ wrapMode: "word",
7768
+ })),
7769
+ commandBlock.omitted > 0
7770
+ ? createText(ctx, `... +${commandBlock.omitted} lines, Ctrl+O to view`, {
7771
+ fg: theme.textMuted,
7772
+ wrapMode: "word",
7773
+ })
7774
+ : null,
7775
+ ].filter((node) => !!node)));
7776
+ }
7470
7777
  if (detailLines.length > 0) {
7471
7778
  children.push(createBox(ctx, {
7472
7779
  paddingLeft: 2,
@@ -7506,6 +7813,20 @@ function shouldRenderTraceGroupAsRawTool(tool) {
7506
7813
  function traceGroupDetailLines(group) {
7507
7814
  return group.previewLines.length > 0 ? group.previewLines : group.items;
7508
7815
  }
7816
+ const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
7817
+ function executeInlineBudget(group, width) {
7818
+ return Math.max(14, width - group.title.length - 20);
7819
+ }
7820
+ // Returns the wrapped command block for execute groups, or null when the
7821
+ // command is short enough to live inline in the header (nothing clipped).
7822
+ function executeCommandBlockFor(group, width) {
7823
+ if (group.kind !== "execute")
7824
+ return null;
7825
+ if (shouldInlineExecuteCommand(group, executeInlineBudget(group, width)))
7826
+ return null;
7827
+ const block = executeCommandBlock(group, EXECUTE_COMMAND_BLOCK_MAX_LINES);
7828
+ return block.lines.length > 0 ? block : null;
7829
+ }
7509
7830
  function traceGroupStatus(group) {
7510
7831
  if (group.hasError) {
7511
7832
  const count = group.errorCount || 1;
@@ -7532,8 +7853,15 @@ function traceGroupHeaderStyledText(group, width = 80) {
7532
7853
  const chunks = [
7533
7854
  fg(titleColor)(bold(group.title)),
7534
7855
  ];
7535
- if (group.command) {
7536
- chunks.push(fg(theme.toolText)(` ${truncate(group.command, commandWidth)}`));
7856
+ if (group.kind === "execute" && group.description) {
7857
+ chunks.push(fg(theme.toolText)(` ${truncate(group.description, commandWidth)}`));
7858
+ }
7859
+ else if (group.command) {
7860
+ // Execute commands only render inline when they fit whole; longer ones
7861
+ // move to the wrapped command block below instead of being clipped here.
7862
+ if (group.kind !== "execute" || shouldInlineExecuteCommand(group, commandWidth)) {
7863
+ chunks.push(fg(theme.toolText)(` ${truncate(group.command, commandWidth)}`));
7864
+ }
7537
7865
  }
7538
7866
  else if (group.count !== undefined && group.noun) {
7539
7867
  chunks.push(fg(theme.textMuted)(` ${group.count} ${group.noun}`));
@@ -7544,6 +7872,8 @@ function traceGroupHeaderStyledText(group, width = 80) {
7544
7872
  return new StyledText(chunks);
7545
7873
  }
7546
7874
  function traceGroupCompactLabel(group) {
7875
+ if (group.description)
7876
+ return `${group.title} ${group.description}`;
7547
7877
  if (group.command)
7548
7878
  return `${group.title} ${group.command}`;
7549
7879
  if (group.count !== undefined && group.noun)
@@ -7573,6 +7903,8 @@ function traceGroupRenderableSignature(group) {
7573
7903
  group.count ?? "",
7574
7904
  group.noun ?? "",
7575
7905
  group.command ?? "",
7906
+ group.description ?? "",
7907
+ hashString(stableStringify(group.commandLines ?? [])),
7576
7908
  group.omitted,
7577
7909
  hashString(stableStringify(group.items)),
7578
7910
  hashString(stableStringify(group.previewLines)),
@@ -7896,14 +8228,15 @@ function createUserEntry(ctx, message, index, key, signature) {
7896
8228
  wrapMode: "word",
7897
8229
  });
7898
8230
  refs.userText = text;
7899
- const queuedText = createText(ctx, message.queued ? " QUEUED " : "", {
8231
+ const inputBadge = userInputStatusBadgeLabel(message.inputStatus);
8232
+ const queuedText = createText(ctx, inputBadge ? ` ${inputBadge} ` : "", {
7900
8233
  fg: theme.background,
7901
8234
  bg: theme.primary,
7902
8235
  });
7903
8236
  refs.userQueuedText = queuedText;
7904
8237
  const queuedBox = createBox(ctx, {
7905
8238
  paddingTop: 1,
7906
- visible: message.queued === true,
8239
+ visible: !!inputBadge,
7907
8240
  }, [queuedText]);
7908
8241
  refs.userQueuedBox = queuedBox;
7909
8242
  const node = createBox(ctx, {
@@ -7944,6 +8277,7 @@ function createErrorEntry(ctx, message, key, signature) {
7944
8277
  return { key, signature, node, refs };
7945
8278
  }
7946
8279
  function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, verboseTrace = false, expandedWrites = new Set(), onToggleWrite) {
8280
+ message = sanitizeDisplayMessage(message);
7947
8281
  const modelSwitch = parseModelSwitchMessage(message.content);
7948
8282
  if (modelSwitch && !message.reasoning?.trim() && !(message.toolCalls?.length)) {
7949
8283
  return createModelSwitchEntry(ctx, modelSwitch, key, signature);
@@ -8109,7 +8443,7 @@ function createCompactionCardEntry(ctx, message, key, signature, expanded, onTog
8109
8443
  statsParts.push(`${meta.turns} turn${meta.turns === 1 ? "" : "s"}`);
8110
8444
  if (meta?.messages)
8111
8445
  statsParts.push(`${meta.messages} message${meta.messages === 1 ? "" : "s"}`);
8112
- const statsLine = statsParts.length > 0 ? statsParts.join(" · ") : "Compacted";
8446
+ const statsLine = statsParts.length > 0 ? `${statsParts.join(" · ")} collapsed` : "Collapsed";
8113
8447
  const children = [];
8114
8448
  const headerRow = createBox(ctx, {
8115
8449
  flexDirection: "row",
@@ -8118,8 +8452,8 @@ function createCompactionCardEntry(ctx, message, key, signature, expanded, onTog
8118
8452
  alignItems: "center",
8119
8453
  }, [
8120
8454
  createText(ctx, new StyledText([
8121
- fg(theme.info)(bold("◈ Context Compacted")),
8122
- ]), { width: 20 }),
8455
+ fg(theme.info)(bold("◈ Earlier Conversation")),
8456
+ ]), { width: 23 }),
8123
8457
  createText(ctx, new StyledText([
8124
8458
  fg(theme.textMuted)(`─ ${statsLine}`),
8125
8459
  ])),
@@ -8327,10 +8661,15 @@ function renderTraceGroup(group, syntaxStyle, width = 80) {
8327
8661
  const status = traceGroupStatus(group);
8328
8662
  const detailColor = traceGroupDetailColor(group);
8329
8663
  const detailWidth = Math.max(20, width - 10);
8664
+ const commandBlock = executeCommandBlockFor(group, width);
8330
8665
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", {
8331
8666
  content: traceGroupHeaderStyledText(group, width),
8332
8667
  wrapMode: "none",
8333
- }), detailLines.length > 0
8668
+ }), commandBlock
8669
+ ? h("box", { paddingLeft: 2, flexDirection: "column", flexShrink: 0 }, ...commandBlock.lines.map((line, index) => h("text", { fg: theme.toolText, wrapMode: "word" }, `${index === 0 ? "$ " : " "}${line}`)), commandBlock.omitted > 0
8670
+ ? h("text", { fg: theme.textMuted, wrapMode: "word" }, `... +${commandBlock.omitted} lines, Ctrl+O to view`)
8671
+ : null)
8672
+ : null, detailLines.length > 0
8334
8673
  ? h("box", { paddingLeft: 2, flexDirection: "column", flexShrink: 0 }, detailLines.map((line, index) => h("text", {
8335
8674
  fg: detailColor,
8336
8675
  wrapMode: "word",
@@ -8419,13 +8758,7 @@ function renderFooter(input) {
8419
8758
  idleContent: `${shortCwd(input.cwd)} idle`,
8420
8759
  idleFg: theme.textMuted,
8421
8760
  runningFg: theme.primary,
8422
- }), h("text", {
8423
- fg: permissionModeColor(input.mode()),
8424
- ref: input.registerModeBadge,
8425
- }, footerPermissionModeText(input.mode())), h("text", {
8426
- fg: input.traceVerbose?.() ? theme.warning : theme.textMuted,
8427
- ref: input.registerTraceBadge,
8428
- }, footerTraceModeText(input.traceVerbose?.() === true)), h("box", { flexGrow: 1 }));
8761
+ }), h("box", { flexGrow: 1 }));
8429
8762
  }
8430
8763
  function pickerTitle(kind, providerId) {
8431
8764
  switch (kind) {
@@ -8695,9 +9028,22 @@ function reconstructDisplayMessages(agentMessages) {
8695
9028
  : "pending",
8696
9029
  });
8697
9030
  }
9031
+ // The aborted-assistant interruption note is model-facing bookkeeping —
9032
+ // strip it so it never renders as something the assistant "said".
9033
+ const interrupted = message.error?.aborted === true;
9034
+ let content = message.content;
9035
+ if (interrupted) {
9036
+ content = content === INTERRUPTED_ASSISTANT_CONTENT
9037
+ ? ""
9038
+ : content.endsWith(`\n\n${INTERRUPTED_ASSISTANT_CONTENT}`)
9039
+ ? content.slice(0, -`\n\n${INTERRUPTED_ASSISTANT_CONTENT}`.length)
9040
+ : content;
9041
+ if (!content && !message.reasoning && toolCalls.length === 0)
9042
+ continue;
9043
+ }
8698
9044
  result.push({
8699
9045
  role: "assistant",
8700
- content: message.content,
9046
+ content,
8701
9047
  reasoning: message.reasoning || undefined,
8702
9048
  toolCalls: toolCalls.length ? toolCalls : undefined,
8703
9049
  });
@@ -8716,7 +9062,7 @@ function renderTranscript(messages, options, syntaxStyle, subtleSyntaxStyle) {
8716
9062
  return items;
8717
9063
  }
8718
9064
  function renderSessionMessages(messages, syntaxStyle, subtleSyntaxStyle, showThinking = true, verboseTrace = false) {
8719
- const visibleMessages = messages.filter((message) => hasRenderableMessage(message, showThinking));
9065
+ const visibleMessages = sanitizeDisplayMessages(messages).filter((message) => hasRenderableMessage(message, showThinking));
8720
9066
  if (!visibleMessages.length)
8721
9067
  return null;
8722
9068
  return visibleMessages.map((message, index) => renderMessage(message, index, syntaxStyle, subtleSyntaxStyle, showThinking, verboseTrace));
@@ -8724,7 +9070,7 @@ function renderSessionMessages(messages, syntaxStyle, subtleSyntaxStyle, showThi
8724
9070
  function formatTranscript(messages, options) {
8725
9071
  const showThinking = options?.showThinking ?? true;
8726
9072
  const verboseTrace = options?.verboseTrace ?? false;
8727
- const visibleMessages = messages.filter((message) => hasRenderableMessage(message, showThinking));
9073
+ const visibleMessages = sanitizeDisplayMessages(messages).filter((message) => hasRenderableMessage(message, showThinking));
8728
9074
  const chunks = [];
8729
9075
  const append = (content, color = theme.text) => {
8730
9076
  if (content)
@@ -8873,6 +9219,15 @@ function appendTraceGroupTranscript(chunks, group) {
8873
9219
  appendLine("");
8874
9220
  if (group.pending)
8875
9221
  return;
9222
+ // Verbose mode shows the full command with its original line structure
9223
+ // whenever the header line alone doesn't already carry it verbatim.
9224
+ const commandLines = group.commandLines ?? [];
9225
+ if (group.kind === "execute" && (group.description || commandLines.length > 1)) {
9226
+ for (const [index, line] of commandLines.entries()) {
9227
+ append(" ", theme.borderSubtle);
9228
+ appendLine(`${index === 0 ? "$ " : " "}${line}`, theme.toolText);
9229
+ }
9230
+ }
8876
9231
  const detailLines = traceGroupDetailLines(group);
8877
9232
  const detailColor = traceGroupDetailColor(group);
8878
9233
  for (const [index, line] of detailLines.entries()) {
@@ -8901,6 +9256,7 @@ function renderHomeState(input) {
8901
9256
  }, h("box", { flexDirection: "column", flexShrink: 0, width: "100%" }, h("text", { fg: theme.text }, ""), h("text", { fg: theme.text }, ""), ...logoLines.map((line) => renderHomeLogoLine(line, width)), h("text", { fg: theme.text }, ""), h("text", { fg: theme.warning }, centerLine(`● Tip ${input.tip}`, width)), cwd ? h("text", { fg: theme.textMuted }, centerLine(` ${cwd}`, width)) : null));
8902
9257
  }
8903
9258
  function hasRenderableMessage(message, showThinking = true) {
9259
+ message = sanitizeDisplayMessage(message);
8904
9260
  if (message.role === "error")
8905
9261
  return !!message.content.trim();
8906
9262
  if (message.role === "user")
@@ -9057,17 +9413,6 @@ function permissionModeBadgeLabel(mode) {
9057
9413
  case "bypassPermissions": return "Bypass";
9058
9414
  }
9059
9415
  }
9060
- function footerPermissionModeText(mode) {
9061
- const info = PERMISSION_MODE_INFO[mode];
9062
- if (mode === "default")
9063
- return " mode: build · shift+tab plan";
9064
- if (mode === "plan")
9065
- return " mode: plan · shift+tab bypass";
9066
- return ` mode: ${info.shortTitle} · shift+tab build`;
9067
- }
9068
- function footerTraceModeText(verbose) {
9069
- return verbose ? " trace: verbose · ctrl+o compact" : " trace: compact · ctrl+o verbose";
9070
- }
9071
9416
  function permissionModeColor(mode) {
9072
9417
  const info = PERMISSION_MODE_INFO[mode];
9073
9418
  switch (info.color) {