@bubblebrain-ai/bubble 0.0.20 → 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 (49) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +5 -1
  3. package/dist/checkpoints.d.ts +57 -0
  4. package/dist/checkpoints.js +0 -0
  5. package/dist/feishu/agent-host/run-driver.js +1 -0
  6. package/dist/main.js +54 -13
  7. package/dist/session.d.ts +31 -0
  8. package/dist/session.js +69 -0
  9. package/dist/slash-commands/commands.js +80 -0
  10. package/dist/slash-commands/types.d.ts +4 -0
  11. package/dist/tools/bash.js +4 -0
  12. package/dist/tools/edit.d.ts +2 -1
  13. package/dist/tools/edit.js +2 -1
  14. package/dist/tools/index.d.ts +7 -0
  15. package/dist/tools/index.js +2 -2
  16. package/dist/tools/write.d.ts +2 -1
  17. package/dist/tools/write.js +2 -1
  18. package/dist/tui/image-paste.d.ts +18 -0
  19. package/dist/tui/image-paste.js +60 -0
  20. package/dist/tui/run.js +309 -69
  21. package/dist/tui/trace-groups.d.ts +16 -0
  22. package/dist/tui/trace-groups.js +42 -1
  23. package/dist/tui/transcript-scroll.d.ts +25 -0
  24. package/dist/tui/transcript-scroll.js +20 -0
  25. package/dist/tui-ink/app.d.ts +4 -1
  26. package/dist/tui-ink/app.js +301 -247
  27. package/dist/tui-ink/display-history.d.ts +16 -1
  28. package/dist/tui-ink/display-history.js +50 -21
  29. package/dist/tui-ink/footer.d.ts +6 -12
  30. package/dist/tui-ink/footer.js +10 -29
  31. package/dist/tui-ink/image-paste.d.ts +59 -0
  32. package/dist/tui-ink/image-paste.js +277 -0
  33. package/dist/tui-ink/input-box.d.ts +26 -1
  34. package/dist/tui-ink/input-box.js +171 -41
  35. package/dist/tui-ink/message-list.d.ts +1 -1
  36. package/dist/tui-ink/message-list.js +46 -29
  37. package/dist/tui-ink/run.d.ts +7 -2
  38. package/dist/tui-ink/run.js +73 -23
  39. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  40. package/dist/tui-ink/terminal-mouse.js +4 -0
  41. package/dist/tui-ink/trace-groups.d.ts +16 -0
  42. package/dist/tui-ink/trace-groups.js +50 -2
  43. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  44. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  45. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  46. package/dist/tui-ink/transcript-viewport.js +83 -0
  47. package/dist/tui-ink/welcome.d.ts +9 -7
  48. package/dist/tui-ink/welcome.js +7 -33
  49. package/package.json +1 -1
package/dist/tui/run.js CHANGED
@@ -6,7 +6,7 @@ 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";
@@ -41,15 +41,16 @@ import { submitFeedback, FeedbackSubmitError } from "../feedback/submit.js";
41
41
  import { createFrames } from "./opencode-spinner.js";
42
42
  import { copyTextToClipboard } from "./clipboard.js";
43
43
  import { readGitSidebarState } from "./sidebar-state.js";
44
- 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
45
  import { createPastedContentMarker, decodePastedBytes, expandPastedContentMarkers, shouldCollapsePastedContent, } from "./paste-placeholder.js";
46
46
  import { isModeCycleKeyEvent, isModeCycleSequence, isModifiedEnterSequence, PROMPT_TEXTAREA_KEYBINDINGS, } from "./prompt-keybindings.js";
47
47
  import { keyNameFromEvent, keyNameFromSequence } from "./global-key-router.js";
48
48
  import { EscapeConfirmationGate } from "./escape-confirmation.js";
49
49
  import { appendHistoryEntry, loadHistorySync, pushHistoryEntry } from "./input-history.js";
50
- import { buildTraceGroups, traceGroupLabel } from "./trace-groups.js";
50
+ import { buildTraceGroups, executeCommandBlock, shouldInlineExecuteCommand, traceGroupLabel, } from "./trace-groups.js";
51
51
  import { sessionDisplayName } from "./session-display.js";
52
52
  import { bubbleWordmarkForWidth, bubbleWordmarkLineText, } from "./wordmark.js";
53
+ import { resolveTranscriptScroll } from "./transcript-scroll.js";
53
54
  import { bootstrapConfig } from "../feishu/config.js";
54
55
  import { ScopeRegistry } from "../feishu/scope/scope-registry.js";
55
56
  const treeSitterClient = getTreeSitterClient();
@@ -477,6 +478,10 @@ function OpenTuiApp(props) {
477
478
  let promptHistory = initialPromptHistory(displayMessages);
478
479
  let nextImageAttachmentIndex = nextImageLabelIndex(displayMessages);
479
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();
480
485
  // Long pastes are collapsed to "[Pasted text #N +M lines]" in the composer
481
486
  // and expanded back to the full content when the message is submitted,
482
487
  // mirroring how image attachments use "[Image #N]" labels.
@@ -550,6 +555,10 @@ function OpenTuiApp(props) {
550
555
  let scrollbox;
551
556
  let transcriptScrollFollowing = true;
552
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;
553
562
  let rootBox;
554
563
  let sidebarShell;
555
564
  let homeSurfaceShell;
@@ -638,8 +647,6 @@ function OpenTuiApp(props) {
638
647
  const providerDialogFooters = [];
639
648
  const promptModeLabels = new Set();
640
649
  const promptModelLabels = new Set();
641
- let footerModeBadge;
642
- let footerTraceBadge;
643
650
  let sidebarTokenText;
644
651
  let sidebarPercentText;
645
652
  let sidebarGaugeText;
@@ -811,7 +818,6 @@ function OpenTuiApp(props) {
811
818
  feishuSetupAbortController?.abort();
812
819
  promptModeLabels.clear();
813
820
  promptModelLabels.clear();
814
- footerModeBadge = undefined;
815
821
  });
816
822
  function showCopyToast(toast, ttl = 2200) {
817
823
  if (copyToastClearTimer)
@@ -1129,9 +1135,7 @@ function OpenTuiApp(props) {
1129
1135
  }
1130
1136
  const promptModeTitle = () => mode() === "plan" ? "Plan" : "Build";
1131
1137
  const promptModeBadge = () => promptModeBadgeContent(mode());
1132
- const footerModeText = () => footerPermissionModeText(mode());
1133
1138
  const effectiveShowThinking = () => showThinking() || verboseTrace();
1134
- const footerTraceText = () => footerTraceModeText(verboseTrace());
1135
1139
  function syncModeChrome() {
1136
1140
  if (uiDisposed)
1137
1141
  return;
@@ -1139,22 +1143,12 @@ function OpenTuiApp(props) {
1139
1143
  if (!safeSetText(label, promptModeBadge()))
1140
1144
  promptModeLabels.delete(label);
1141
1145
  }
1142
- if (footerModeBadge) {
1143
- footerModeBadge.fg = permissionModeColor(mode());
1144
- if (!safeSetText(footerModeBadge, footerModeText()))
1145
- footerModeBadge = undefined;
1146
- }
1147
1146
  safeRequestRender(sessionComposerShell);
1148
1147
  safeRequestRender(rootBox);
1149
1148
  }
1150
1149
  function syncTraceChrome() {
1151
1150
  if (uiDisposed)
1152
1151
  return;
1153
- if (footerTraceBadge) {
1154
- footerTraceBadge.fg = verboseTrace() ? theme.warning : theme.textMuted;
1155
- if (!safeSetText(footerTraceBadge, footerTraceText()))
1156
- footerTraceBadge = undefined;
1157
- }
1158
1152
  safeRequestRender(rootBox);
1159
1153
  }
1160
1154
  const registerPromptModeLabel = (ref) => {
@@ -1182,21 +1176,6 @@ function OpenTuiApp(props) {
1182
1176
  if (!safeSetText(ref, promptModelTitle()))
1183
1177
  promptModelLabels.delete(ref);
1184
1178
  };
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
1179
  const cycleMode = () => {
1201
1180
  if (picker || pendingPlan() || isRunning())
1202
1181
  return false;
@@ -1502,7 +1481,13 @@ function OpenTuiApp(props) {
1502
1481
  setTimeout(() => {
1503
1482
  if (!scrollbox)
1504
1483
  return;
1505
- if (shouldFollow && transcriptScrollFollowing) {
1484
+ const action = resolveTranscriptScroll({
1485
+ forcePending: transcriptForceScrollPending,
1486
+ shouldFollow,
1487
+ following: transcriptScrollFollowing,
1488
+ });
1489
+ if (action === "scroll-bottom") {
1490
+ transcriptForceScrollPending = false;
1506
1491
  scrollTranscriptToBottom();
1507
1492
  }
1508
1493
  else {
@@ -1511,6 +1496,7 @@ function OpenTuiApp(props) {
1511
1496
  }, delay);
1512
1497
  }
1513
1498
  function handleTranscriptMouseScroll() {
1499
+ transcriptForceScrollPending = false;
1514
1500
  setTimeout(updateTranscriptScrollFollowingFromPosition, 0);
1515
1501
  }
1516
1502
  function syncQuestionUI(focusCustom = false) {
@@ -2460,8 +2446,8 @@ function OpenTuiApp(props) {
2460
2446
  function queuedInputLabel(count = queuedInputCount()) {
2461
2447
  return `${count} queued message${count === 1 ? "" : "s"}`;
2462
2448
  }
2463
- function redrawTranscriptWithQueuedDisplays() {
2464
- redrawTranscript(streamingDisplay, displayMessages);
2449
+ function redrawTranscriptWithQueuedDisplays(options = {}) {
2450
+ redrawTranscript(streamingDisplay, displayMessages, options);
2465
2451
  }
2466
2452
  function addUserInputStatusDisplay(input, inputStatus) {
2467
2453
  const displayId = `queued-${++nextQueuedDisplayId}`;
@@ -2469,7 +2455,9 @@ function OpenTuiApp(props) {
2469
2455
  ...queuedDisplayMessages,
2470
2456
  { role: "user", content: input, clientId: displayId, inputStatus },
2471
2457
  ];
2472
- 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 });
2473
2461
  return displayId;
2474
2462
  }
2475
2463
  function addQueuedUserDisplay(input) {
@@ -2955,6 +2943,7 @@ function OpenTuiApp(props) {
2955
2943
  if (options.forceFollow) {
2956
2944
  transcriptScrollFollowing = true;
2957
2945
  transcriptScrollInitialized = true;
2946
+ transcriptForceScrollPending = true;
2958
2947
  }
2959
2948
  const nextMessages = compactDisplayMessages([
2960
2949
  ...baseMessages,
@@ -3048,7 +3037,12 @@ function OpenTuiApp(props) {
3048
3037
  step,
3049
3038
  providerId,
3050
3039
  query: "",
3051
- 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,
3052
3046
  apiKey: "",
3053
3047
  };
3054
3048
  activePrompt()?.clear();
@@ -3079,6 +3073,10 @@ function OpenTuiApp(props) {
3079
3073
  return providerId ? buildPickerItems("provider-auth", providerId) : [];
3080
3074
  if (step === "skills")
3081
3075
  return buildSkillItems();
3076
+ if (step === "rewind")
3077
+ return buildRewindPickerItems();
3078
+ if (step === "rewind-action")
3079
+ return buildRewindActionItems(providerId);
3082
3080
  if (step === "models") {
3083
3081
  if (providerDialogModelItems?.key === modelPickerCacheKey(providerId)) {
3084
3082
  return providerDialogModelItems.items;
@@ -3317,6 +3315,10 @@ function OpenTuiApp(props) {
3317
3315
  return "Connect a provider";
3318
3316
  if (state.step === "skills")
3319
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?";
3320
3322
  const provider = providerDisplayName(state.providerId);
3321
3323
  if (state.step === "auth")
3322
3324
  return `${provider} auth method`;
@@ -3337,6 +3339,10 @@ function OpenTuiApp(props) {
3337
3339
  }
3338
3340
  if (state.step === "skills")
3339
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";
3340
3346
  const escLabel = state.step === "providers" ? "esc close" : "esc back";
3341
3347
  return `↑/↓ move · enter select · ${escLabel}${count}`;
3342
3348
  }
@@ -3356,7 +3362,15 @@ function OpenTuiApp(props) {
3356
3362
  const contentWidth = Math.max(24, panelWidth - PROVIDER_DIALOG_ROW_RESERVED_WIDTH);
3357
3363
  const footer = state.step === "skills" ? 10 : state.step === "providers" ? 9 : 8;
3358
3364
  const minLabel = state.step === "skills" ? 18 : 24;
3359
- const desiredDetail = state.step === "skills" ? 30 : state.step === "providers" ? 24 : 16;
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;
3360
3374
  const detail = Math.max(8, Math.min(desiredDetail, contentWidth - footer - minLabel));
3361
3375
  const label = Math.max(8, contentWidth - detail - footer);
3362
3376
  return { label, detail, footer };
@@ -3424,8 +3438,8 @@ function OpenTuiApp(props) {
3424
3438
  else if (state.step === "key") {
3425
3439
  openProviderDialog(state.providerId && registry.supportsOAuth(state.providerId) ? "auth" : "providers", state.providerId);
3426
3440
  }
3427
- else if (state.step === "models" || state.step === "skills") {
3428
- closeProviderDialog();
3441
+ else if (state.step === "rewind-action") {
3442
+ openProviderDialog("rewind");
3429
3443
  }
3430
3444
  else {
3431
3445
  closeProviderDialog();
@@ -3556,6 +3570,20 @@ function OpenTuiApp(props) {
3556
3570
  await executeSlash(item.command);
3557
3571
  return;
3558
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
+ }
3559
3587
  if (state.step === "skills") {
3560
3588
  closeProviderDialog();
3561
3589
  insertSkillPrompt(item.value);
@@ -4680,9 +4708,54 @@ function OpenTuiApp(props) {
4680
4708
  const references = [...pendingPastedTexts.entries()].map(([marker, content]) => ({ marker, content }));
4681
4709
  return expandPastedContentMarkers(text, references);
4682
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
+ }
4683
4728
  function handleComposerPaste(event) {
4684
4729
  const text = typeof event.text === "string" ? event.text : decodePastedBytes(event.bytes);
4685
- if (!text || !shouldCollapsePastedContent(text))
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))
4686
4759
  return;
4687
4760
  event.preventDefault?.();
4688
4761
  const marker = createPastedContentMarker(text, nextPastedTextIndex);
@@ -4692,6 +4765,62 @@ function OpenTuiApp(props) {
4692
4765
  prompt?.insertText(marker);
4693
4766
  onPromptContentChange(readPromptText());
4694
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
+ }
4695
4824
  async function expandTextParts(parts) {
4696
4825
  const expandedParts = [];
4697
4826
  for (const part of parts) {
@@ -4712,6 +4841,8 @@ function OpenTuiApp(props) {
4712
4841
  }
4713
4842
  async function handleInput(input, options = {}) {
4714
4843
  setNotice("");
4844
+ if (pendingImageIngestions.size > 0)
4845
+ await Promise.all([...pendingImageIngestions]);
4715
4846
  const labeledInput = buildImageContentPartsFromLabels(input, pendingImageAttachments);
4716
4847
  if (labeledInput.actualInput) {
4717
4848
  await runAgentInput(await expandTextParts(labeledInput.actualInput), labeledInput.displayInput, options);
@@ -4775,6 +4906,14 @@ function OpenTuiApp(props) {
4775
4906
  openPicker: (kind, providerId) => {
4776
4907
  void openPicker(kind, providerId);
4777
4908
  },
4909
+ openRewindPicker: () => {
4910
+ openProviderDialog("rewind");
4911
+ },
4912
+ fillComposer: (text) => {
4913
+ resetPromptHistoryBrowse();
4914
+ setPromptText(text);
4915
+ redrawDock();
4916
+ },
4778
4917
  registry,
4779
4918
  skillRegistry: skills,
4780
4919
  bashAllowlist: props.options.bashAllowlist,
@@ -4821,6 +4960,13 @@ function OpenTuiApp(props) {
4821
4960
  redrawTranscript(undefined, displayMessages);
4822
4961
  setTimeout(() => setNotice(""), 4000);
4823
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
+ }
4824
4970
  else {
4825
4971
  addMessage("assistant", result);
4826
4972
  }
@@ -5092,6 +5238,48 @@ function OpenTuiApp(props) {
5092
5238
  command: `/logout ${provider.id}`,
5093
5239
  }));
5094
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
+ }
5095
5283
  function buildSkillItems() {
5096
5284
  return skills.summaries().map((skill) => {
5097
5285
  const tags = skill.tags && skill.tags.length > 0 ? ` · ${skill.tags.join(", ")}` : "";
@@ -5216,7 +5404,9 @@ function OpenTuiApp(props) {
5216
5404
  if (!reusedQueuedDisplay)
5217
5405
  displayMessages = nextMessages;
5218
5406
  streamingDisplay = undefined;
5219
- 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 });
5220
5410
  const taskStartedAt = Date.now();
5221
5411
  const run = beginAgentRun();
5222
5412
  traceEvent("tui_agent_run_begin", {
@@ -6916,12 +7106,8 @@ function OpenTuiApp(props) {
6916
7106
  ]),
6917
7107
  renderFooter({
6918
7108
  cwd: props.args.cwd,
6919
- mode,
6920
7109
  running: isRunning,
6921
7110
  registerScanner: registerPromptScanner,
6922
- registerModeBadge: registerFooterModeBadge,
6923
- traceVerbose: verboseTrace,
6924
- registerTraceBadge: registerFooterTraceBadge,
6925
7111
  }),
6926
7112
  renderProviderDialog(),
6927
7113
  renderStatsPanel(),
@@ -7569,6 +7755,25 @@ function createTraceGroupRenderable(ctx, group, syntaxStyle, width = 80) {
7569
7755
  const children = [
7570
7756
  createText(ctx, traceGroupHeaderStyledText(group, width), { wrapMode: "none" }),
7571
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
+ }
7572
7777
  if (detailLines.length > 0) {
7573
7778
  children.push(createBox(ctx, {
7574
7779
  paddingLeft: 2,
@@ -7608,6 +7813,20 @@ function shouldRenderTraceGroupAsRawTool(tool) {
7608
7813
  function traceGroupDetailLines(group) {
7609
7814
  return group.previewLines.length > 0 ? group.previewLines : group.items;
7610
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
+ }
7611
7830
  function traceGroupStatus(group) {
7612
7831
  if (group.hasError) {
7613
7832
  const count = group.errorCount || 1;
@@ -7634,8 +7853,15 @@ function traceGroupHeaderStyledText(group, width = 80) {
7634
7853
  const chunks = [
7635
7854
  fg(titleColor)(bold(group.title)),
7636
7855
  ];
7637
- if (group.command) {
7638
- 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
+ }
7639
7865
  }
7640
7866
  else if (group.count !== undefined && group.noun) {
7641
7867
  chunks.push(fg(theme.textMuted)(` ${group.count} ${group.noun}`));
@@ -7646,6 +7872,8 @@ function traceGroupHeaderStyledText(group, width = 80) {
7646
7872
  return new StyledText(chunks);
7647
7873
  }
7648
7874
  function traceGroupCompactLabel(group) {
7875
+ if (group.description)
7876
+ return `${group.title} ${group.description}`;
7649
7877
  if (group.command)
7650
7878
  return `${group.title} ${group.command}`;
7651
7879
  if (group.count !== undefined && group.noun)
@@ -7675,6 +7903,8 @@ function traceGroupRenderableSignature(group) {
7675
7903
  group.count ?? "",
7676
7904
  group.noun ?? "",
7677
7905
  group.command ?? "",
7906
+ group.description ?? "",
7907
+ hashString(stableStringify(group.commandLines ?? [])),
7678
7908
  group.omitted,
7679
7909
  hashString(stableStringify(group.items)),
7680
7910
  hashString(stableStringify(group.previewLines)),
@@ -8431,10 +8661,15 @@ function renderTraceGroup(group, syntaxStyle, width = 80) {
8431
8661
  const status = traceGroupStatus(group);
8432
8662
  const detailColor = traceGroupDetailColor(group);
8433
8663
  const detailWidth = Math.max(20, width - 10);
8664
+ const commandBlock = executeCommandBlockFor(group, width);
8434
8665
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", {
8435
8666
  content: traceGroupHeaderStyledText(group, width),
8436
8667
  wrapMode: "none",
8437
- }), 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
8438
8673
  ? h("box", { paddingLeft: 2, flexDirection: "column", flexShrink: 0 }, detailLines.map((line, index) => h("text", {
8439
8674
  fg: detailColor,
8440
8675
  wrapMode: "word",
@@ -8523,13 +8758,7 @@ function renderFooter(input) {
8523
8758
  idleContent: `${shortCwd(input.cwd)} idle`,
8524
8759
  idleFg: theme.textMuted,
8525
8760
  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 }));
8761
+ }), h("box", { flexGrow: 1 }));
8533
8762
  }
8534
8763
  function pickerTitle(kind, providerId) {
8535
8764
  switch (kind) {
@@ -8799,9 +9028,22 @@ function reconstructDisplayMessages(agentMessages) {
8799
9028
  : "pending",
8800
9029
  });
8801
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
+ }
8802
9044
  result.push({
8803
9045
  role: "assistant",
8804
- content: message.content,
9046
+ content,
8805
9047
  reasoning: message.reasoning || undefined,
8806
9048
  toolCalls: toolCalls.length ? toolCalls : undefined,
8807
9049
  });
@@ -8977,6 +9219,15 @@ function appendTraceGroupTranscript(chunks, group) {
8977
9219
  appendLine("");
8978
9220
  if (group.pending)
8979
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
+ }
8980
9231
  const detailLines = traceGroupDetailLines(group);
8981
9232
  const detailColor = traceGroupDetailColor(group);
8982
9233
  for (const [index, line] of detailLines.entries()) {
@@ -9162,17 +9413,6 @@ function permissionModeBadgeLabel(mode) {
9162
9413
  case "bypassPermissions": return "Bypass";
9163
9414
  }
9164
9415
  }
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
9416
  function permissionModeColor(mode) {
9177
9417
  const info = PERMISSION_MODE_INFO[mode];
9178
9418
  switch (info.color) {