@bubblebrain-ai/bubble 0.0.20 → 0.0.22

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 (98) hide show
  1. package/dist/agent/abort-errors.d.ts +14 -0
  2. package/dist/agent/abort-errors.js +21 -0
  3. package/dist/agent/budget-ledger.d.ts +41 -0
  4. package/dist/agent/budget-ledger.js +64 -0
  5. package/dist/agent/child-runner.d.ts +55 -0
  6. package/dist/agent/child-runner.js +312 -0
  7. package/dist/agent/profiles.d.ts +8 -0
  8. package/dist/agent/profiles.js +27 -5
  9. package/dist/agent/result-integrator.d.ts +22 -0
  10. package/dist/agent/result-integrator.js +50 -0
  11. package/dist/agent/subagent-control.d.ts +31 -0
  12. package/dist/agent/subagent-control.js +27 -0
  13. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  14. package/dist/agent/subagent-scheduler.d.ts +95 -0
  15. package/dist/agent/subagent-scheduler.js +256 -0
  16. package/dist/agent/subagent-store.d.ts +41 -0
  17. package/dist/agent/subagent-store.js +149 -0
  18. package/dist/agent/subagent-summary.d.ts +30 -0
  19. package/dist/agent/subagent-summary.js +74 -0
  20. package/dist/agent/worktree.d.ts +29 -0
  21. package/dist/agent/worktree.js +73 -0
  22. package/dist/agent.d.ts +64 -5
  23. package/dist/agent.js +365 -288
  24. package/dist/approval/controller.js +9 -1
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +17 -1
  27. package/dist/checkpoints.d.ts +57 -0
  28. package/dist/checkpoints.js +0 -0
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.js +17 -0
  31. package/dist/feishu/agent-host/approval-card.js +9 -0
  32. package/dist/feishu/agent-host/run-driver.js +2 -0
  33. package/dist/main.js +88 -13
  34. package/dist/network/errors.d.ts +28 -0
  35. package/dist/network/errors.js +24 -0
  36. package/dist/orchestrator/default-hooks.js +5 -1
  37. package/dist/prompt/compose.js +3 -0
  38. package/dist/prompt/delegation.d.ts +14 -0
  39. package/dist/prompt/delegation.js +64 -0
  40. package/dist/prompt/task-reminders.d.ts +5 -1
  41. package/dist/prompt/task-reminders.js +10 -2
  42. package/dist/provider-anthropic.js +23 -0
  43. package/dist/provider.js +23 -3
  44. package/dist/session.d.ts +31 -0
  45. package/dist/session.js +69 -0
  46. package/dist/slash-commands/commands.js +109 -2
  47. package/dist/slash-commands/types.d.ts +6 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/bash.js +4 -0
  51. package/dist/tools/child-tools.d.ts +31 -0
  52. package/dist/tools/child-tools.js +106 -0
  53. package/dist/tools/edit.d.ts +2 -1
  54. package/dist/tools/edit.js +2 -1
  55. package/dist/tools/index.d.ts +7 -0
  56. package/dist/tools/index.js +3 -3
  57. package/dist/tools/write.d.ts +2 -1
  58. package/dist/tools/write.js +2 -1
  59. package/dist/tui/image-paste.d.ts +18 -0
  60. package/dist/tui/image-paste.js +60 -0
  61. package/dist/tui/run.d.ts +11 -1
  62. package/dist/tui/run.js +399 -71
  63. package/dist/tui/session-picker-data.d.ts +18 -0
  64. package/dist/tui/session-picker-data.js +21 -0
  65. package/dist/tui/trace-groups.d.ts +16 -0
  66. package/dist/tui/trace-groups.js +42 -1
  67. package/dist/tui/transcript-scroll.d.ts +25 -0
  68. package/dist/tui/transcript-scroll.js +20 -0
  69. package/dist/tui/wordmark.d.ts +2 -0
  70. package/dist/tui/wordmark.js +31 -4
  71. package/dist/tui-ink/app.d.ts +4 -1
  72. package/dist/tui-ink/app.js +301 -247
  73. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  74. package/dist/tui-ink/display-history.d.ts +16 -1
  75. package/dist/tui-ink/display-history.js +50 -21
  76. package/dist/tui-ink/footer.d.ts +6 -12
  77. package/dist/tui-ink/footer.js +10 -29
  78. package/dist/tui-ink/image-paste.d.ts +59 -0
  79. package/dist/tui-ink/image-paste.js +277 -0
  80. package/dist/tui-ink/input-box.d.ts +26 -1
  81. package/dist/tui-ink/input-box.js +171 -41
  82. package/dist/tui-ink/message-list.d.ts +1 -1
  83. package/dist/tui-ink/message-list.js +46 -29
  84. package/dist/tui-ink/run.d.ts +7 -2
  85. package/dist/tui-ink/run.js +73 -23
  86. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  87. package/dist/tui-ink/terminal-mouse.js +4 -0
  88. package/dist/tui-ink/trace-groups.d.ts +16 -0
  89. package/dist/tui-ink/trace-groups.js +50 -2
  90. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  91. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  92. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  93. package/dist/tui-ink/transcript-viewport.js +83 -0
  94. package/dist/tui-ink/welcome.d.ts +9 -7
  95. package/dist/tui-ink/welcome.js +7 -33
  96. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  97. package/dist/types.d.ts +17 -0
  98. package/package.json +1 -1
package/dist/tui/run.js CHANGED
@@ -6,12 +6,14 @@ 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
13
  import { createStreamingInternalReminderSanitizer, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "../agent/internal-reminder-sanitizer.js";
14
14
  import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceValue, traceEvent, } from "../debug-trace.js";
15
+ import { SessionManager } from "../session.js";
16
+ import { buildSessionPickerEntries, preferredSessionPickerIndex } from "./session-picker-data.js";
15
17
  import { BUILTIN_PROVIDERS, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
16
18
  import { calculateUsageCost } from "../model-pricing.js";
17
19
  import { getAvailableThinkingLevels } from "../provider-transform.js";
@@ -41,15 +43,16 @@ import { submitFeedback, FeedbackSubmitError } from "../feedback/submit.js";
41
43
  import { createFrames } from "./opencode-spinner.js";
42
44
  import { copyTextToClipboard } from "./clipboard.js";
43
45
  import { readGitSidebarState } from "./sidebar-state.js";
44
- import { buildImageContentPartsFromLabels, extractImagePathTokens, imageAttachmentLabelPattern, resolveComposerImagePaths, resolveImageInput, } from "./image-paste.js";
46
+ import { buildImageContentPartsFromLabels, bareImageFilenameFromPaste, extractImagePathTokens, imageAttachmentLabelPattern, imageLabelForPath, ingestClipboardImage, ingestImagePath, isImagePathPaste, splitPastedPaths, resolveComposerImagePaths, resolveImageInput, } from "./image-paste.js";
45
47
  import { createPastedContentMarker, decodePastedBytes, expandPastedContentMarkers, shouldCollapsePastedContent, } from "./paste-placeholder.js";
46
48
  import { isModeCycleKeyEvent, isModeCycleSequence, isModifiedEnterSequence, PROMPT_TEXTAREA_KEYBINDINGS, } from "./prompt-keybindings.js";
47
49
  import { keyNameFromEvent, keyNameFromSequence } from "./global-key-router.js";
48
50
  import { EscapeConfirmationGate } from "./escape-confirmation.js";
49
51
  import { appendHistoryEntry, loadHistorySync, pushHistoryEntry } from "./input-history.js";
50
- import { buildTraceGroups, traceGroupLabel } from "./trace-groups.js";
52
+ import { buildTraceGroups, executeCommandBlock, shouldInlineExecuteCommand, traceGroupLabel, } from "./trace-groups.js";
51
53
  import { sessionDisplayName } from "./session-display.js";
52
54
  import { bubbleWordmarkForWidth, bubbleWordmarkLineText, } from "./wordmark.js";
55
+ import { resolveTranscriptScroll } from "./transcript-scroll.js";
53
56
  import { bootstrapConfig } from "../feishu/config.js";
54
57
  import { ScopeRegistry } from "../feishu/scope/scope-registry.js";
55
58
  const treeSitterClient = getTreeSitterClient();
@@ -477,6 +480,10 @@ function OpenTuiApp(props) {
477
480
  let promptHistory = initialPromptHistory(displayMessages);
478
481
  let nextImageAttachmentIndex = nextImageLabelIndex(displayMessages);
479
482
  const pendingImageAttachments = new Map();
483
+ // Image-path pastes insert their [image#N] label immediately and ingest the
484
+ // file in the background; sends await these so a quick Enter can't outrun
485
+ // attachment registration.
486
+ const pendingImageIngestions = new Set();
480
487
  // Long pastes are collapsed to "[Pasted text #N +M lines]" in the composer
481
488
  // and expanded back to the full content when the message is submitted,
482
489
  // mirroring how image attachments use "[Image #N]" labels.
@@ -550,6 +557,10 @@ function OpenTuiApp(props) {
550
557
  let scrollbox;
551
558
  let transcriptScrollFollowing = true;
552
559
  let transcriptScrollInitialized = false;
560
+ // Set by forceFollow renders (user sends, approvals). Survives intervening
561
+ // streaming redraws that recompute follow state from the (still-unscrolled)
562
+ // position before the deferred scroll runs; cleared on user mouse scroll.
563
+ let transcriptForceScrollPending = false;
553
564
  let rootBox;
554
565
  let sidebarShell;
555
566
  let homeSurfaceShell;
@@ -638,8 +649,6 @@ function OpenTuiApp(props) {
638
649
  const providerDialogFooters = [];
639
650
  const promptModeLabels = new Set();
640
651
  const promptModelLabels = new Set();
641
- let footerModeBadge;
642
- let footerTraceBadge;
643
652
  let sidebarTokenText;
644
653
  let sidebarPercentText;
645
654
  let sidebarGaugeText;
@@ -811,7 +820,6 @@ function OpenTuiApp(props) {
811
820
  feishuSetupAbortController?.abort();
812
821
  promptModeLabels.clear();
813
822
  promptModelLabels.clear();
814
- footerModeBadge = undefined;
815
823
  });
816
824
  function showCopyToast(toast, ttl = 2200) {
817
825
  if (copyToastClearTimer)
@@ -1129,9 +1137,7 @@ function OpenTuiApp(props) {
1129
1137
  }
1130
1138
  const promptModeTitle = () => mode() === "plan" ? "Plan" : "Build";
1131
1139
  const promptModeBadge = () => promptModeBadgeContent(mode());
1132
- const footerModeText = () => footerPermissionModeText(mode());
1133
1140
  const effectiveShowThinking = () => showThinking() || verboseTrace();
1134
- const footerTraceText = () => footerTraceModeText(verboseTrace());
1135
1141
  function syncModeChrome() {
1136
1142
  if (uiDisposed)
1137
1143
  return;
@@ -1139,22 +1145,12 @@ function OpenTuiApp(props) {
1139
1145
  if (!safeSetText(label, promptModeBadge()))
1140
1146
  promptModeLabels.delete(label);
1141
1147
  }
1142
- if (footerModeBadge) {
1143
- footerModeBadge.fg = permissionModeColor(mode());
1144
- if (!safeSetText(footerModeBadge, footerModeText()))
1145
- footerModeBadge = undefined;
1146
- }
1147
1148
  safeRequestRender(sessionComposerShell);
1148
1149
  safeRequestRender(rootBox);
1149
1150
  }
1150
1151
  function syncTraceChrome() {
1151
1152
  if (uiDisposed)
1152
1153
  return;
1153
- if (footerTraceBadge) {
1154
- footerTraceBadge.fg = verboseTrace() ? theme.warning : theme.textMuted;
1155
- if (!safeSetText(footerTraceBadge, footerTraceText()))
1156
- footerTraceBadge = undefined;
1157
- }
1158
1154
  safeRequestRender(rootBox);
1159
1155
  }
1160
1156
  const registerPromptModeLabel = (ref) => {
@@ -1182,23 +1178,14 @@ function OpenTuiApp(props) {
1182
1178
  if (!safeSetText(ref, promptModelTitle()))
1183
1179
  promptModelLabels.delete(ref);
1184
1180
  };
1185
- const registerFooterModeBadge = (ref) => {
1186
- if (uiDisposed)
1187
- return;
1188
- footerModeBadge = ref;
1189
- if (!safeSetText(ref, footerModeText()))
1190
- footerModeBadge = undefined;
1191
- };
1192
- const registerFooterTraceBadge = (ref) => {
1193
- if (uiDisposed)
1194
- return;
1195
- footerTraceBadge = ref;
1196
- ref.fg = verboseTrace() ? theme.warning : theme.textMuted;
1197
- if (!safeSetText(ref, footerTraceText()))
1198
- footerTraceBadge = undefined;
1199
- };
1200
1181
  const cycleMode = () => {
1201
- if (picker || pendingPlan() || isRunning())
1182
+ // Mode switching is intentionally allowed while the agent is running:
1183
+ // Agent.setMode() is safe mid-run and the approval controller reads the
1184
+ // live mode on every request, so flipping to bypass (or into plan) takes
1185
+ // effect from the very next tool call — no need to wait for the turn to
1186
+ // finish. Only pickers and the plan-approval dialog still block it,
1187
+ // because those surfaces own the keyboard.
1188
+ if (picker || pendingPlan())
1202
1189
  return false;
1203
1190
  const next = getNextPermissionMode(props.agent.mode);
1204
1191
  props.agent.setMode(next);
@@ -1502,7 +1489,13 @@ function OpenTuiApp(props) {
1502
1489
  setTimeout(() => {
1503
1490
  if (!scrollbox)
1504
1491
  return;
1505
- if (shouldFollow && transcriptScrollFollowing) {
1492
+ const action = resolveTranscriptScroll({
1493
+ forcePending: transcriptForceScrollPending,
1494
+ shouldFollow,
1495
+ following: transcriptScrollFollowing,
1496
+ });
1497
+ if (action === "scroll-bottom") {
1498
+ transcriptForceScrollPending = false;
1506
1499
  scrollTranscriptToBottom();
1507
1500
  }
1508
1501
  else {
@@ -1511,6 +1504,7 @@ function OpenTuiApp(props) {
1511
1504
  }, delay);
1512
1505
  }
1513
1506
  function handleTranscriptMouseScroll() {
1507
+ transcriptForceScrollPending = false;
1514
1508
  setTimeout(updateTranscriptScrollFollowingFromPosition, 0);
1515
1509
  }
1516
1510
  function syncQuestionUI(focusCustom = false) {
@@ -2460,8 +2454,8 @@ function OpenTuiApp(props) {
2460
2454
  function queuedInputLabel(count = queuedInputCount()) {
2461
2455
  return `${count} queued message${count === 1 ? "" : "s"}`;
2462
2456
  }
2463
- function redrawTranscriptWithQueuedDisplays() {
2464
- redrawTranscript(streamingDisplay, displayMessages);
2457
+ function redrawTranscriptWithQueuedDisplays(options = {}) {
2458
+ redrawTranscript(streamingDisplay, displayMessages, options);
2465
2459
  }
2466
2460
  function addUserInputStatusDisplay(input, inputStatus) {
2467
2461
  const displayId = `queued-${++nextQueuedDisplayId}`;
@@ -2469,7 +2463,9 @@ function OpenTuiApp(props) {
2469
2463
  ...queuedDisplayMessages,
2470
2464
  { role: "user", content: input, clientId: displayId, inputStatus },
2471
2465
  ];
2472
- redrawTranscriptWithQueuedDisplays();
2466
+ // Sending a message is explicit user intent to look at the newest turn:
2467
+ // snap to the bottom even if the transcript was scrolled up.
2468
+ redrawTranscriptWithQueuedDisplays({ forceFollow: true });
2473
2469
  return displayId;
2474
2470
  }
2475
2471
  function addQueuedUserDisplay(input) {
@@ -2955,6 +2951,7 @@ function OpenTuiApp(props) {
2955
2951
  if (options.forceFollow) {
2956
2952
  transcriptScrollFollowing = true;
2957
2953
  transcriptScrollInitialized = true;
2954
+ transcriptForceScrollPending = true;
2958
2955
  }
2959
2956
  const nextMessages = compactDisplayMessages([
2960
2957
  ...baseMessages,
@@ -3048,7 +3045,15 @@ function OpenTuiApp(props) {
3048
3045
  step,
3049
3046
  providerId,
3050
3047
  query: "",
3051
- index: step === "models" ? preferredPickerIndex("model", items) : 0,
3048
+ index: step === "models"
3049
+ ? preferredPickerIndex("model", items)
3050
+ // "(current)" sits at the bottom of the rewind list and is the safe default.
3051
+ : step === "rewind"
3052
+ ? Math.max(0, items.length - 1)
3053
+ // Sessions: start on the most recent conversation that is not the active one.
3054
+ : step === "sessions"
3055
+ ? preferredSessionPickerIndex(items)
3056
+ : 0,
3052
3057
  apiKey: "",
3053
3058
  };
3054
3059
  activePrompt()?.clear();
@@ -3079,6 +3084,12 @@ function OpenTuiApp(props) {
3079
3084
  return providerId ? buildPickerItems("provider-auth", providerId) : [];
3080
3085
  if (step === "skills")
3081
3086
  return buildSkillItems();
3087
+ if (step === "rewind")
3088
+ return buildRewindPickerItems();
3089
+ if (step === "rewind-action")
3090
+ return buildRewindActionItems(providerId);
3091
+ if (step === "sessions")
3092
+ return buildSessionPickerItems();
3082
3093
  if (step === "models") {
3083
3094
  if (providerDialogModelItems?.key === modelPickerCacheKey(providerId)) {
3084
3095
  return providerDialogModelItems.items;
@@ -3317,6 +3328,12 @@ function OpenTuiApp(props) {
3317
3328
  return "Connect a provider";
3318
3329
  if (state.step === "skills")
3319
3330
  return "Select skill";
3331
+ if (state.step === "rewind")
3332
+ return "Rewind — restore to the point before…";
3333
+ if (state.step === "rewind-action")
3334
+ return "Rewind — what to restore?";
3335
+ if (state.step === "sessions")
3336
+ return "Resume a session";
3320
3337
  const provider = providerDisplayName(state.providerId);
3321
3338
  if (state.step === "auth")
3322
3339
  return `${provider} auth method`;
@@ -3337,6 +3354,12 @@ function OpenTuiApp(props) {
3337
3354
  }
3338
3355
  if (state.step === "skills")
3339
3356
  return `↑/↓ move · enter insert · esc close${count}`;
3357
+ if (state.step === "rewind")
3358
+ return `↑/↓ move · enter continue · esc cancel${count}`;
3359
+ if (state.step === "rewind-action")
3360
+ return "↑/↓ move · enter confirm · esc back";
3361
+ if (state.step === "sessions")
3362
+ return `↑/↓ move · enter resume · esc close${count}`;
3340
3363
  const escLabel = state.step === "providers" ? "esc close" : "esc back";
3341
3364
  return `↑/↓ move · enter select · ${escLabel}${count}`;
3342
3365
  }
@@ -3354,9 +3377,19 @@ function OpenTuiApp(props) {
3354
3377
  }
3355
3378
  function providerDialogColumnWidths(state, panelWidth) {
3356
3379
  const contentWidth = Math.max(24, panelWidth - PROVIDER_DIALOG_ROW_RESERVED_WIDTH);
3357
- const footer = state.step === "skills" ? 10 : state.step === "providers" ? 9 : 8;
3380
+ const footer = state.step === "skills" || state.step === "sessions" ? 10 : state.step === "providers" ? 9 : 8;
3358
3381
  const minLabel = state.step === "skills" ? 18 : 24;
3359
- const desiredDetail = state.step === "skills" ? 30 : state.step === "providers" ? 24 : 16;
3382
+ const desiredDetail = state.step === "skills"
3383
+ ? 30
3384
+ : state.step === "providers"
3385
+ ? 24
3386
+ : state.step === "rewind-action"
3387
+ ? 40
3388
+ : state.step === "rewind"
3389
+ ? 18
3390
+ : state.step === "sessions"
3391
+ ? 14
3392
+ : 16;
3360
3393
  const detail = Math.max(8, Math.min(desiredDetail, contentWidth - footer - minLabel));
3361
3394
  const label = Math.max(8, contentWidth - detail - footer);
3362
3395
  return { label, detail, footer };
@@ -3424,8 +3457,8 @@ function OpenTuiApp(props) {
3424
3457
  else if (state.step === "key") {
3425
3458
  openProviderDialog(state.providerId && registry.supportsOAuth(state.providerId) ? "auth" : "providers", state.providerId);
3426
3459
  }
3427
- else if (state.step === "models" || state.step === "skills") {
3428
- closeProviderDialog();
3460
+ else if (state.step === "rewind-action") {
3461
+ openProviderDialog("rewind");
3429
3462
  }
3430
3463
  else {
3431
3464
  closeProviderDialog();
@@ -3556,6 +3589,29 @@ function OpenTuiApp(props) {
3556
3589
  await executeSlash(item.command);
3557
3590
  return;
3558
3591
  }
3592
+ if (state.step === "rewind") {
3593
+ if (!item.value) {
3594
+ // "(current)" — keep everything as is.
3595
+ closeProviderDialog();
3596
+ return;
3597
+ }
3598
+ openProviderDialog("rewind-action", item.value);
3599
+ return;
3600
+ }
3601
+ if (state.step === "sessions") {
3602
+ closeProviderDialog();
3603
+ if (!item.value || item.value === props.options.sessionManager?.getSessionFile()) {
3604
+ // Selecting the active session keeps everything as is.
3605
+ return;
3606
+ }
3607
+ await switchToSession(item.value);
3608
+ return;
3609
+ }
3610
+ if (state.step === "rewind-action") {
3611
+ closeProviderDialog();
3612
+ await executeSlash(item.command);
3613
+ return;
3614
+ }
3559
3615
  if (state.step === "skills") {
3560
3616
  closeProviderDialog();
3561
3617
  insertSkillPrompt(item.value);
@@ -4680,9 +4736,54 @@ function OpenTuiApp(props) {
4680
4736
  const references = [...pendingPastedTexts.entries()].map(([marker, content]) => ({ marker, content }));
4681
4737
  return expandPastedContentMarkers(text, references);
4682
4738
  }
4739
+ // Inserts [image#N] labels at the cursor, padding with spaces when the
4740
+ // paste lands glued to surrounding text. Returns false when no prompt is
4741
+ // mounted (the caller should leave the paste alone).
4742
+ function insertComposerImageLabels(event, labels) {
4743
+ const prompt = activePrompt();
4744
+ if (!prompt)
4745
+ return false;
4746
+ event.preventDefault?.();
4747
+ const current = prompt.plainText ?? "";
4748
+ const offset = Math.min(Math.max(prompt.cursorOffset ?? current.length, 0), current.length);
4749
+ const needsLead = offset > 0 && !/\s/.test(current[offset - 1] ?? "");
4750
+ const needsTrail = offset < current.length && !/\s/.test(current[offset] ?? "");
4751
+ const joined = labels.map((label) => `[${label}]`).join(" ");
4752
+ prompt.insertText(`${needsLead ? " " : ""}${joined}${needsTrail ? " " : ""}`);
4753
+ onPromptContentChange(readPromptText());
4754
+ return true;
4755
+ }
4683
4756
  function handleComposerPaste(event) {
4684
4757
  const text = typeof event.text === "string" ? event.text : decodePastedBytes(event.bytes);
4685
- if (!text || !shouldCollapsePastedContent(text))
4758
+ if (isImagePathPaste(text)) {
4759
+ // Insert the final [image#N] label at paste time and ingest the file in
4760
+ // the background. Inserting the raw path and swapping it later flashes
4761
+ // the path and resets the cursor (setPromptText jumps it to the end).
4762
+ const entries = splitPastedPaths(text).map((rawPath) => ({
4763
+ rawPath,
4764
+ label: imageLabelForPath(rawPath, nextImageAttachmentIndex),
4765
+ }));
4766
+ if (!insertComposerImageLabels(event, entries.map((entry) => entry.label)))
4767
+ return;
4768
+ nextImageAttachmentIndex += entries.length;
4769
+ trackImagePathIngestion(entries);
4770
+ return;
4771
+ }
4772
+ // Copying an image file in Finder pastes only the file's NAME; Cmd+V of
4773
+ // raw image data pastes nothing at all. Both leave the real bits on the
4774
+ // system clipboard, so attach from there.
4775
+ const bareName = bareImageFilenameFromPaste(text);
4776
+ if (bareName || !text.trim()) {
4777
+ const label = bareName
4778
+ ? imageLabelForPath(bareName, nextImageAttachmentIndex)
4779
+ : `image#${nextImageAttachmentIndex}.png`;
4780
+ if (!insertComposerImageLabels(event, [label]))
4781
+ return;
4782
+ nextImageAttachmentIndex += 1;
4783
+ trackClipboardImageIngestion(label, text);
4784
+ return;
4785
+ }
4786
+ if (!shouldCollapsePastedContent(text))
4686
4787
  return;
4687
4788
  event.preventDefault?.();
4688
4789
  const marker = createPastedContentMarker(text, nextPastedTextIndex);
@@ -4692,6 +4793,62 @@ function OpenTuiApp(props) {
4692
4793
  prompt?.insertText(marker);
4693
4794
  onPromptContentChange(readPromptText());
4694
4795
  }
4796
+ function trackImageIngestion(task) {
4797
+ pendingImageIngestions.add(task);
4798
+ void task.finally(() => pendingImageIngestions.delete(task));
4799
+ }
4800
+ function trackImagePathIngestion(entries) {
4801
+ trackImageIngestion((async () => {
4802
+ for (const { rawPath, label } of entries) {
4803
+ const result = await ingestImagePath(rawPath);
4804
+ if (result.attachment) {
4805
+ pendingImageAttachments.set(label, result.attachment);
4806
+ }
4807
+ else {
4808
+ addMessage("error", `Skipped image: ${rawPath}: ${result.error ?? "could not attach image"}`);
4809
+ replaceComposerImageLabel(label, "");
4810
+ }
4811
+ }
4812
+ })());
4813
+ }
4814
+ function trackClipboardImageIngestion(label, originalText) {
4815
+ trackImageIngestion((async () => {
4816
+ const result = await ingestClipboardImage();
4817
+ if (result.attachment) {
4818
+ pendingImageAttachments.set(label, result.attachment);
4819
+ return;
4820
+ }
4821
+ const restored = originalText.trim();
4822
+ // A filename-looking text paste with no image on the clipboard is just
4823
+ // text — restore it quietly. Only an empty paste (Cmd+V of image data)
4824
+ // warrants an error, since there is nothing to restore.
4825
+ if (!restored)
4826
+ addMessage("error", `Could not attach image from clipboard: ${result.error ?? "unknown error"}`);
4827
+ replaceComposerImageLabel(label, restored);
4828
+ })());
4829
+ }
4830
+ // Swaps a failed image label for its replacement (or drops it) without
4831
+ // moving the cursor relative to the surrounding text.
4832
+ function replaceComposerImageLabel(label, replacement) {
4833
+ const prompt = activePrompt();
4834
+ const current = prompt?.plainText ?? promptText;
4835
+ const token = `[${label}]`;
4836
+ const start = current.indexOf(token);
4837
+ if (start < 0)
4838
+ return;
4839
+ let end = start + token.length;
4840
+ if (!replacement && current[end] === " ")
4841
+ end += 1;
4842
+ const next = current.slice(0, start) + replacement + current.slice(end);
4843
+ if (prompt) {
4844
+ const cursor = Math.min(Math.max(prompt.cursorOffset ?? next.length, 0), current.length);
4845
+ prompt.setText(next);
4846
+ prompt.cursorOffset = cursor <= start
4847
+ ? cursor
4848
+ : Math.min(next.length, Math.max(start + replacement.length, cursor - (end - start) + replacement.length));
4849
+ }
4850
+ onPromptContentChange(next);
4851
+ }
4695
4852
  async function expandTextParts(parts) {
4696
4853
  const expandedParts = [];
4697
4854
  for (const part of parts) {
@@ -4712,6 +4869,8 @@ function OpenTuiApp(props) {
4712
4869
  }
4713
4870
  async function handleInput(input, options = {}) {
4714
4871
  setNotice("");
4872
+ if (pendingImageIngestions.size > 0)
4873
+ await Promise.all([...pendingImageIngestions]);
4715
4874
  const labeledInput = buildImageContentPartsFromLabels(input, pendingImageAttachments);
4716
4875
  if (labeledInput.actualInput) {
4717
4876
  await runAgentInput(await expandTextParts(labeledInput.actualInput), labeledInput.displayInput, options);
@@ -4775,6 +4934,17 @@ function OpenTuiApp(props) {
4775
4934
  openPicker: (kind, providerId) => {
4776
4935
  void openPicker(kind, providerId);
4777
4936
  },
4937
+ openRewindPicker: () => {
4938
+ openProviderDialog("rewind");
4939
+ },
4940
+ openSessionPicker: () => {
4941
+ openProviderDialog("sessions");
4942
+ },
4943
+ fillComposer: (text) => {
4944
+ resetPromptHistoryBrowse();
4945
+ setPromptText(text);
4946
+ redrawDock();
4947
+ },
4778
4948
  registry,
4779
4949
  skillRegistry: skills,
4780
4950
  bashAllowlist: props.options.bashAllowlist,
@@ -4821,6 +4991,13 @@ function OpenTuiApp(props) {
4821
4991
  redrawTranscript(undefined, displayMessages);
4822
4992
  setTimeout(() => setNotice(""), 4000);
4823
4993
  }
4994
+ else if (result.startsWith("⏪")) {
4995
+ // /rewind truncated agent.messages — rebuild the transcript from the
4996
+ // rewound state before appending the summary.
4997
+ displayMessages = reconstructDisplayMessages(props.agent.messages);
4998
+ streamingDisplay = undefined;
4999
+ addMessage("assistant", result);
5000
+ }
4824
5001
  else {
4825
5002
  addMessage("assistant", result);
4826
5003
  }
@@ -5092,6 +5269,86 @@ function OpenTuiApp(props) {
5092
5269
  command: `/logout ${provider.id}`,
5093
5270
  }));
5094
5271
  }
5272
+ function buildRewindPickerItems() {
5273
+ const session = props.options.sessionManager;
5274
+ if (!session)
5275
+ return [];
5276
+ const checkpoints = session.getCheckpoints();
5277
+ const items = session.listUserTurns().map((turn, index) => {
5278
+ const files = checkpoints.filesTouchedAt(turn.id).length;
5279
+ return {
5280
+ label: turn.preview,
5281
+ detail: files > 0 ? `${files} file${files === 1 ? "" : "s"} changed` : "No code changes",
5282
+ value: String(index + 1),
5283
+ command: `/rewind ${index + 1}`,
5284
+ };
5285
+ });
5286
+ // Selecting "(current)" keeps everything as is — mirrors Claude Code.
5287
+ items.push({ label: "(current)", value: "", command: "" });
5288
+ return items;
5289
+ }
5290
+ function buildSessionPickerItems() {
5291
+ const activeFile = props.options.sessionManager?.getSessionFile();
5292
+ const summaries = SessionManager.summarizeSessionsForCwd(props.args.cwd);
5293
+ return buildSessionPickerEntries(summaries, activeFile).map((entry) => ({
5294
+ label: entry.label,
5295
+ detail: entry.detail,
5296
+ value: entry.value,
5297
+ command: "",
5298
+ footer: entry.footer,
5299
+ gutter: entry.gutter,
5300
+ }));
5301
+ }
5302
+ async function switchToSession(sessionFile) {
5303
+ const switchSession = props.options.switchSession;
5304
+ if (!switchSession) {
5305
+ addMessage("error", "Session switching is not available in this mode.");
5306
+ return;
5307
+ }
5308
+ if (isRunning()) {
5309
+ setNotice("Stop the current run before switching sessions.");
5310
+ return;
5311
+ }
5312
+ const result = switchSession(sessionFile);
5313
+ if ("error" in result) {
5314
+ addMessage("error", `Failed to switch session: ${result.error}`);
5315
+ return;
5316
+ }
5317
+ props.options.sessionManager = result.manager;
5318
+ // Same rebuild path as /rewind: the agent history was replaced wholesale,
5319
+ // so reconstruct the transcript from it instead of patching the display.
5320
+ displayMessages = reconstructDisplayMessages(props.agent.messages);
5321
+ streamingDisplay = undefined;
5322
+ redrawTranscript(undefined, displayMessages);
5323
+ syncTodosFromAgent();
5324
+ bumpSidebar();
5325
+ syncPromptSurfaces(true);
5326
+ addMessage("assistant", `⤷ Resumed session: ${sessionDisplayName(result.manager)}`);
5327
+ }
5328
+ function buildRewindActionItems(turnNumber) {
5329
+ if (!turnNumber)
5330
+ return [];
5331
+ return [
5332
+ {
5333
+ label: "Restore conversation and code",
5334
+ detail: "Rewind the chat and undo tracked file edits",
5335
+ value: turnNumber,
5336
+ command: `/rewind ${turnNumber}`,
5337
+ },
5338
+ {
5339
+ label: "Restore conversation only",
5340
+ detail: "Keep file changes on disk",
5341
+ value: turnNumber,
5342
+ command: `/rewind ${turnNumber} --chat`,
5343
+ },
5344
+ {
5345
+ label: "Restore code only",
5346
+ detail: "Undo tracked file edits, keep the conversation",
5347
+ value: turnNumber,
5348
+ command: `/rewind ${turnNumber} --code`,
5349
+ },
5350
+ ];
5351
+ }
5095
5352
  function buildSkillItems() {
5096
5353
  return skills.summaries().map((skill) => {
5097
5354
  const tags = skill.tags && skill.tags.length > 0 ? ` · ${skill.tags.join(", ")}` : "";
@@ -5216,7 +5473,9 @@ function OpenTuiApp(props) {
5216
5473
  if (!reusedQueuedDisplay)
5217
5474
  displayMessages = nextMessages;
5218
5475
  streamingDisplay = undefined;
5219
- redrawTranscript(undefined, nextMessages);
5476
+ // The user just sent this message — re-engage bottom-follow so the new
5477
+ // turn is visible even if they had scrolled up to read earlier history.
5478
+ redrawTranscript(undefined, nextMessages, { forceFollow: true });
5220
5479
  const taskStartedAt = Date.now();
5221
5480
  const run = beginAgentRun();
5222
5481
  traceEvent("tui_agent_run_begin", {
@@ -6916,12 +7175,8 @@ function OpenTuiApp(props) {
6916
7175
  ]),
6917
7176
  renderFooter({
6918
7177
  cwd: props.args.cwd,
6919
- mode,
6920
7178
  running: isRunning,
6921
7179
  registerScanner: registerPromptScanner,
6922
- registerModeBadge: registerFooterModeBadge,
6923
- traceVerbose: verboseTrace,
6924
- registerTraceBadge: registerFooterTraceBadge,
6925
7180
  }),
6926
7181
  renderProviderDialog(),
6927
7182
  renderStatsPanel(),
@@ -7569,6 +7824,25 @@ function createTraceGroupRenderable(ctx, group, syntaxStyle, width = 80) {
7569
7824
  const children = [
7570
7825
  createText(ctx, traceGroupHeaderStyledText(group, width), { wrapMode: "none" }),
7571
7826
  ];
7827
+ const commandBlock = executeCommandBlockFor(group, width);
7828
+ if (commandBlock) {
7829
+ children.push(createBox(ctx, {
7830
+ paddingLeft: 2,
7831
+ flexDirection: "column",
7832
+ flexShrink: 0,
7833
+ }, [
7834
+ ...commandBlock.lines.map((line, index) => createText(ctx, `${index === 0 ? "$ " : " "}${line}`, {
7835
+ fg: theme.toolText,
7836
+ wrapMode: "word",
7837
+ })),
7838
+ commandBlock.omitted > 0
7839
+ ? createText(ctx, `... +${commandBlock.omitted} lines, Ctrl+O to view`, {
7840
+ fg: theme.textMuted,
7841
+ wrapMode: "word",
7842
+ })
7843
+ : null,
7844
+ ].filter((node) => !!node)));
7845
+ }
7572
7846
  if (detailLines.length > 0) {
7573
7847
  children.push(createBox(ctx, {
7574
7848
  paddingLeft: 2,
@@ -7608,6 +7882,20 @@ function shouldRenderTraceGroupAsRawTool(tool) {
7608
7882
  function traceGroupDetailLines(group) {
7609
7883
  return group.previewLines.length > 0 ? group.previewLines : group.items;
7610
7884
  }
7885
+ const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
7886
+ function executeInlineBudget(group, width) {
7887
+ return Math.max(14, width - group.title.length - 20);
7888
+ }
7889
+ // Returns the wrapped command block for execute groups, or null when the
7890
+ // command is short enough to live inline in the header (nothing clipped).
7891
+ function executeCommandBlockFor(group, width) {
7892
+ if (group.kind !== "execute")
7893
+ return null;
7894
+ if (shouldInlineExecuteCommand(group, executeInlineBudget(group, width)))
7895
+ return null;
7896
+ const block = executeCommandBlock(group, EXECUTE_COMMAND_BLOCK_MAX_LINES);
7897
+ return block.lines.length > 0 ? block : null;
7898
+ }
7611
7899
  function traceGroupStatus(group) {
7612
7900
  if (group.hasError) {
7613
7901
  const count = group.errorCount || 1;
@@ -7634,8 +7922,15 @@ function traceGroupHeaderStyledText(group, width = 80) {
7634
7922
  const chunks = [
7635
7923
  fg(titleColor)(bold(group.title)),
7636
7924
  ];
7637
- if (group.command) {
7638
- chunks.push(fg(theme.toolText)(` ${truncate(group.command, commandWidth)}`));
7925
+ if (group.kind === "execute" && group.description) {
7926
+ chunks.push(fg(theme.toolText)(` ${truncate(group.description, commandWidth)}`));
7927
+ }
7928
+ else if (group.command) {
7929
+ // Execute commands only render inline when they fit whole; longer ones
7930
+ // move to the wrapped command block below instead of being clipped here.
7931
+ if (group.kind !== "execute" || shouldInlineExecuteCommand(group, commandWidth)) {
7932
+ chunks.push(fg(theme.toolText)(` ${truncate(group.command, commandWidth)}`));
7933
+ }
7639
7934
  }
7640
7935
  else if (group.count !== undefined && group.noun) {
7641
7936
  chunks.push(fg(theme.textMuted)(` ${group.count} ${group.noun}`));
@@ -7646,6 +7941,8 @@ function traceGroupHeaderStyledText(group, width = 80) {
7646
7941
  return new StyledText(chunks);
7647
7942
  }
7648
7943
  function traceGroupCompactLabel(group) {
7944
+ if (group.description)
7945
+ return `${group.title} ${group.description}`;
7649
7946
  if (group.command)
7650
7947
  return `${group.title} ${group.command}`;
7651
7948
  if (group.count !== undefined && group.noun)
@@ -7675,6 +7972,8 @@ function traceGroupRenderableSignature(group) {
7675
7972
  group.count ?? "",
7676
7973
  group.noun ?? "",
7677
7974
  group.command ?? "",
7975
+ group.description ?? "",
7976
+ hashString(stableStringify(group.commandLines ?? [])),
7678
7977
  group.omitted,
7679
7978
  hashString(stableStringify(group.items)),
7680
7979
  hashString(stableStringify(group.previewLines)),
@@ -8431,10 +8730,15 @@ function renderTraceGroup(group, syntaxStyle, width = 80) {
8431
8730
  const status = traceGroupStatus(group);
8432
8731
  const detailColor = traceGroupDetailColor(group);
8433
8732
  const detailWidth = Math.max(20, width - 10);
8733
+ const commandBlock = executeCommandBlockFor(group, width);
8434
8734
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", {
8435
8735
  content: traceGroupHeaderStyledText(group, width),
8436
8736
  wrapMode: "none",
8437
- }), detailLines.length > 0
8737
+ }), commandBlock
8738
+ ? 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
8739
+ ? h("text", { fg: theme.textMuted, wrapMode: "word" }, `... +${commandBlock.omitted} lines, Ctrl+O to view`)
8740
+ : null)
8741
+ : null, detailLines.length > 0
8438
8742
  ? h("box", { paddingLeft: 2, flexDirection: "column", flexShrink: 0 }, detailLines.map((line, index) => h("text", {
8439
8743
  fg: detailColor,
8440
8744
  wrapMode: "word",
@@ -8523,13 +8827,7 @@ function renderFooter(input) {
8523
8827
  idleContent: `${shortCwd(input.cwd)} idle`,
8524
8828
  idleFg: theme.textMuted,
8525
8829
  runningFg: theme.primary,
8526
- }), h("text", {
8527
- fg: permissionModeColor(input.mode()),
8528
- ref: input.registerModeBadge,
8529
- }, footerPermissionModeText(input.mode())), h("text", {
8530
- fg: input.traceVerbose?.() ? theme.warning : theme.textMuted,
8531
- ref: input.registerTraceBadge,
8532
- }, footerTraceModeText(input.traceVerbose?.() === true)), h("box", { flexGrow: 1 }));
8830
+ }), h("box", { flexGrow: 1 }));
8533
8831
  }
8534
8832
  function pickerTitle(kind, providerId) {
8535
8833
  switch (kind) {
@@ -8799,9 +9097,22 @@ function reconstructDisplayMessages(agentMessages) {
8799
9097
  : "pending",
8800
9098
  });
8801
9099
  }
9100
+ // The aborted-assistant interruption note is model-facing bookkeeping —
9101
+ // strip it so it never renders as something the assistant "said".
9102
+ const interrupted = message.error?.aborted === true;
9103
+ let content = message.content;
9104
+ if (interrupted) {
9105
+ content = content === INTERRUPTED_ASSISTANT_CONTENT
9106
+ ? ""
9107
+ : content.endsWith(`\n\n${INTERRUPTED_ASSISTANT_CONTENT}`)
9108
+ ? content.slice(0, -`\n\n${INTERRUPTED_ASSISTANT_CONTENT}`.length)
9109
+ : content;
9110
+ if (!content && !message.reasoning && toolCalls.length === 0)
9111
+ continue;
9112
+ }
8802
9113
  result.push({
8803
9114
  role: "assistant",
8804
- content: message.content,
9115
+ content,
8805
9116
  reasoning: message.reasoning || undefined,
8806
9117
  toolCalls: toolCalls.length ? toolCalls : undefined,
8807
9118
  });
@@ -8977,6 +9288,15 @@ function appendTraceGroupTranscript(chunks, group) {
8977
9288
  appendLine("");
8978
9289
  if (group.pending)
8979
9290
  return;
9291
+ // Verbose mode shows the full command with its original line structure
9292
+ // whenever the header line alone doesn't already carry it verbatim.
9293
+ const commandLines = group.commandLines ?? [];
9294
+ if (group.kind === "execute" && (group.description || commandLines.length > 1)) {
9295
+ for (const [index, line] of commandLines.entries()) {
9296
+ append(" ", theme.borderSubtle);
9297
+ appendLine(`${index === 0 ? "$ " : " "}${line}`, theme.toolText);
9298
+ }
9299
+ }
8980
9300
  const detailLines = traceGroupDetailLines(group);
8981
9301
  const detailColor = traceGroupDetailColor(group);
8982
9302
  for (const [index, line] of detailLines.entries()) {
@@ -9089,6 +9409,17 @@ function getApprovalPanelMeta(request) {
9089
9409
  path: request.path,
9090
9410
  };
9091
9411
  }
9412
+ if (request.type === "agent_profile") {
9413
+ return {
9414
+ icon: "@",
9415
+ title: `Trust project agent profile "${request.name}"`,
9416
+ subtitle: "from .bubble/agents — its prompt will drive a subagent",
9417
+ preview: `${shortCwd(request.path)}\n${request.promptPreview}`,
9418
+ previewHeight: 8,
9419
+ previewColor: theme.toolText,
9420
+ path: request.path,
9421
+ };
9422
+ }
9092
9423
  const path = shortCwd(request.path);
9093
9424
  if (request.type === "edit") {
9094
9425
  return {
@@ -9162,17 +9493,6 @@ function permissionModeBadgeLabel(mode) {
9162
9493
  case "bypassPermissions": return "Bypass";
9163
9494
  }
9164
9495
  }
9165
- function footerPermissionModeText(mode) {
9166
- const info = PERMISSION_MODE_INFO[mode];
9167
- if (mode === "default")
9168
- return " mode: build · shift+tab plan";
9169
- if (mode === "plan")
9170
- return " mode: plan · shift+tab bypass";
9171
- return ` mode: ${info.shortTitle} · shift+tab build`;
9172
- }
9173
- function footerTraceModeText(verbose) {
9174
- return verbose ? " trace: verbose · ctrl+o compact" : " trace: compact · ctrl+o verbose";
9175
- }
9176
9496
  function permissionModeColor(mode) {
9177
9497
  const info = PERMISSION_MODE_INFO[mode];
9178
9498
  switch (info.color) {
@@ -9214,6 +9534,8 @@ function displayToolName(name) {
9214
9534
  wait_agent: "WaitAgent",
9215
9535
  send_input: "SendInput",
9216
9536
  close_agent: "CloseAgent",
9537
+ list_agents: "ListAgents",
9538
+ agent_team: "AgentTeam",
9217
9539
  task: "Task",
9218
9540
  todo: "Todo",
9219
9541
  question: "Questions",
@@ -9236,6 +9558,12 @@ function toolHeader(tool) {
9236
9558
  const agentId = args.agent_id ?? (Array.isArray(args.agent_ids) ? `${args.agent_ids.length} agents` : undefined);
9237
9559
  return agentId ? `(${truncate(String(agentId), 64)})` : "";
9238
9560
  }
9561
+ if (tool.name === "agent_team") {
9562
+ const items = Array.isArray(args.items) ? `${args.items.length} items` : "";
9563
+ const description = typeof args.description === "string" ? args.description : "";
9564
+ const label = [description, items].filter(Boolean).join(", ");
9565
+ return label ? `(${truncate(label, 64)})` : "";
9566
+ }
9239
9567
  const value = args.path ?? args.command ?? args.pattern ?? args.url ?? args.query ?? toolPath(tool);
9240
9568
  return value ? `(${truncate(String(value).replace(/\n/g, " "), 64)})` : "";
9241
9569
  }