@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.
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +5 -1
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/feishu/agent-host/run-driver.js +1 -0
- package/dist/main.js +54 -13
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +80 -0
- package/dist/slash-commands/types.d.ts +4 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +2 -1
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/run.js +309 -69
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +42 -1
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +301 -247
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +50 -2
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- 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
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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 === "
|
|
3428
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
7638
|
-
chunks.push(fg(theme.toolText)(` ${truncate(group.
|
|
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
|
-
}),
|
|
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("
|
|
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
|
|
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) {
|