@bubblebrain-ai/bubble 0.0.21 → 0.0.23
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/README.md +197 -34
- package/dist/agent/abort-errors.d.ts +14 -0
- package/dist/agent/abort-errors.js +21 -0
- package/dist/agent/budget-ledger.d.ts +41 -0
- package/dist/agent/budget-ledger.js +64 -0
- package/dist/agent/child-runner.d.ts +55 -0
- package/dist/agent/child-runner.js +312 -0
- package/dist/agent/internal-reminder-sanitizer.js +29 -9
- package/dist/agent/profiles.d.ts +8 -0
- package/dist/agent/profiles.js +27 -5
- package/dist/agent/result-integrator.d.ts +22 -0
- package/dist/agent/result-integrator.js +50 -0
- package/dist/agent/subagent-control.d.ts +31 -0
- package/dist/agent/subagent-control.js +27 -0
- package/dist/agent/subagent-lifecycle-reminder.js +11 -2
- package/dist/agent/subagent-scheduler.d.ts +95 -0
- package/dist/agent/subagent-scheduler.js +256 -0
- package/dist/agent/subagent-store.d.ts +41 -0
- package/dist/agent/subagent-store.js +149 -0
- package/dist/agent/subagent-summary.d.ts +30 -0
- package/dist/agent/subagent-summary.js +74 -0
- package/dist/agent/worktree.d.ts +29 -0
- package/dist/agent/worktree.js +73 -0
- package/dist/agent.d.ts +63 -5
- package/dist/agent.js +360 -287
- package/dist/approval/controller.js +9 -1
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +17 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +17 -0
- package/dist/feishu/agent-host/approval-card.js +9 -0
- package/dist/feishu/agent-host/run-driver.js +1 -0
- package/dist/main.js +38 -2
- package/dist/model-catalog.js +6 -0
- package/dist/network/errors.d.ts +28 -0
- package/dist/network/errors.js +24 -0
- package/dist/orchestrator/default-hooks.js +5 -1
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/delegation.d.ts +14 -0
- package/dist/prompt/delegation.js +64 -0
- package/dist/prompt/task-reminders.d.ts +5 -1
- package/dist/prompt/task-reminders.js +10 -2
- package/dist/provider-anthropic.js +23 -0
- package/dist/provider-transform.js +14 -0
- package/dist/provider.js +23 -3
- package/dist/slash-commands/commands.js +29 -2
- package/dist/slash-commands/types.d.ts +2 -0
- package/dist/tools/agent-lifecycle.d.ts +29 -3
- package/dist/tools/agent-lifecycle.js +394 -40
- package/dist/tools/child-tools.d.ts +31 -0
- package/dist/tools/child-tools.js +106 -0
- package/dist/tools/index.js +1 -1
- package/dist/tui/run.d.ts +17 -1
- package/dist/tui/run.js +155 -10
- package/dist/tui/session-picker-data.d.ts +18 -0
- package/dist/tui/session-picker-data.js +21 -0
- package/dist/tui/trace-groups.js +41 -5
- package/dist/tui/wordmark.d.ts +2 -0
- package/dist/tui/wordmark.js +31 -4
- package/dist/tui-ink/approval/approval-dialog.js +10 -0
- package/dist/tui-opentui/approval/approval-dialog.js +10 -0
- package/dist/types.d.ts +17 -0
- package/dist/update/index.d.ts +18 -4
- package/dist/update/index.js +41 -19
- package/package.json +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-child tool factory for write_worktree subagents (design doc §8).
|
|
3
|
+
*
|
|
4
|
+
* Parent tools close over the parent cwd at creation, so a write child needs
|
|
5
|
+
* fresh instances bound to its worktree — with their own FileStateTracker —
|
|
6
|
+
* plus a worktree-scoped approval policy: file operations are runtime-checked
|
|
7
|
+
* to stay under the worktree root (the tools' own workspace fence does this
|
|
8
|
+
* structurally), bash auto-approves inside the worktree when the command
|
|
9
|
+
* passes a deny-list of escaping operations, and everything else fails fast.
|
|
10
|
+
*/
|
|
11
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
12
|
+
import { createBashTool } from "./bash.js";
|
|
13
|
+
import { createEditTool } from "./edit.js";
|
|
14
|
+
import { createGlobTool } from "./glob.js";
|
|
15
|
+
import { createGrepTool } from "./grep.js";
|
|
16
|
+
import { createReadTool } from "./read.js";
|
|
17
|
+
import { createWriteTool } from "./write.js";
|
|
18
|
+
import { FileStateTracker } from "./file-state.js";
|
|
19
|
+
/** Operations a worktree child may never run, regardless of cwd. */
|
|
20
|
+
const WORKTREE_BASH_DENY_PATTERNS = [
|
|
21
|
+
{ pattern: /\bgit\s+push\b/, reason: "pushing from a subagent worktree is not allowed; the parent reviews and applies changes." },
|
|
22
|
+
{ pattern: /\bgit\s+remote\b/, reason: "remote configuration is not allowed inside a subagent worktree." },
|
|
23
|
+
{ pattern: /\bgit\s+worktree\b/, reason: "managing worktrees from inside a subagent worktree is not allowed." },
|
|
24
|
+
{ pattern: /\bsudo\b/, reason: "privileged commands are not allowed inside a subagent worktree." },
|
|
25
|
+
];
|
|
26
|
+
export function isPathInsideWorktree(worktreeRoot, candidate) {
|
|
27
|
+
const resolved = isAbsolute(candidate) ? resolve(candidate) : resolve(worktreeRoot, candidate);
|
|
28
|
+
const root = resolve(worktreeRoot);
|
|
29
|
+
return resolved === root || resolved.startsWith(root + sep);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Approval policy for a worktree child: containment is enforced by code
|
|
33
|
+
* (path checks, deny-list), never by prompt text. There is no interactive
|
|
34
|
+
* fallback — anything outside the policy fails fast (design §11).
|
|
35
|
+
*/
|
|
36
|
+
export class WorktreeApprovalController {
|
|
37
|
+
worktreeRoot;
|
|
38
|
+
constructor(worktreeRoot) {
|
|
39
|
+
this.worktreeRoot = worktreeRoot;
|
|
40
|
+
}
|
|
41
|
+
async request(req) {
|
|
42
|
+
switch (req.type) {
|
|
43
|
+
case "bash": {
|
|
44
|
+
for (const { pattern, reason } of WORKTREE_BASH_DENY_PATTERNS) {
|
|
45
|
+
if (pattern.test(req.command)) {
|
|
46
|
+
return { action: "reject", feedback: `Blocked by worktree policy: ${reason}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!isPathInsideWorktree(this.worktreeRoot, req.cwd)) {
|
|
50
|
+
return { action: "reject", feedback: "Blocked by worktree policy: commands must run inside the subagent worktree." };
|
|
51
|
+
}
|
|
52
|
+
// Absolute paths reaching outside the worktree are an escape attempt.
|
|
53
|
+
const absolutePaths = req.command.match(/(?<=^|[\s"'=])\/[^\s"';|&]+/g) ?? [];
|
|
54
|
+
for (const path of absolutePaths) {
|
|
55
|
+
if (path.startsWith("/dev/") || path.startsWith("/tmp/") || path.startsWith("/usr/") || path.startsWith("/bin/") || path.startsWith("/opt/") || path.startsWith("/etc/"))
|
|
56
|
+
continue;
|
|
57
|
+
if (!isPathInsideWorktree(this.worktreeRoot, path)) {
|
|
58
|
+
return {
|
|
59
|
+
action: "reject",
|
|
60
|
+
feedback: `Blocked by worktree policy: the command references a path outside the worktree (${path}).`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { action: "approve" };
|
|
65
|
+
}
|
|
66
|
+
case "edit":
|
|
67
|
+
case "write":
|
|
68
|
+
return isPathInsideWorktree(this.worktreeRoot, req.path)
|
|
69
|
+
? { action: "approve" }
|
|
70
|
+
: { action: "reject", feedback: `Blocked by worktree policy: ${req.path} is outside the subagent worktree.` };
|
|
71
|
+
case "patch":
|
|
72
|
+
return req.paths.every((path) => isPathInsideWorktree(this.worktreeRoot, path))
|
|
73
|
+
? { action: "approve" }
|
|
74
|
+
: { action: "reject", feedback: "Blocked by worktree policy: the patch touches paths outside the subagent worktree." };
|
|
75
|
+
case "lsp":
|
|
76
|
+
return { action: "approve" };
|
|
77
|
+
case "agent_profile":
|
|
78
|
+
return { action: "reject", feedback: "Subagents cannot approve agent profiles." };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
checkRules() {
|
|
82
|
+
return { decision: "ask" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const WORKTREE_TOOL_NAMES = new Set(["read", "glob", "grep", "edit", "write", "bash"]);
|
|
86
|
+
/**
|
|
87
|
+
* Builds the write child's toolset bound to its worktree: fresh instances
|
|
88
|
+
* with their own FileStateTracker and the worktree approval policy. A
|
|
89
|
+
* profile's tools list can narrow the set but never widen it.
|
|
90
|
+
*/
|
|
91
|
+
export function createWorktreeChildTools(worktreeCwd, include) {
|
|
92
|
+
const approval = new WorktreeApprovalController(worktreeCwd);
|
|
93
|
+
const fileState = new FileStateTracker(worktreeCwd);
|
|
94
|
+
const tools = [
|
|
95
|
+
createReadTool(worktreeCwd, approval, undefined, fileState),
|
|
96
|
+
createGlobTool(worktreeCwd),
|
|
97
|
+
createGrepTool(worktreeCwd),
|
|
98
|
+
createEditTool(worktreeCwd, approval, undefined, fileState),
|
|
99
|
+
createWriteTool(worktreeCwd, {}, approval, undefined, fileState),
|
|
100
|
+
createBashTool(worktreeCwd, approval, fileState),
|
|
101
|
+
];
|
|
102
|
+
if (!include || include.length === 0)
|
|
103
|
+
return tools;
|
|
104
|
+
const requested = new Set(include.filter((name) => WORKTREE_TOOL_NAMES.has(name)));
|
|
105
|
+
return requested.size > 0 ? tools.filter((tool) => requested.has(tool.name)) : tools;
|
|
106
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -57,7 +57,7 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
|
|
|
57
57
|
createWebFetchTool(approval),
|
|
58
58
|
createMemorySearchTool(cwd),
|
|
59
59
|
createMemoryReadSummaryTool(cwd),
|
|
60
|
-
...createAgentLifecycleTools(),
|
|
60
|
+
...createAgentLifecycleTools({ cwd, approval }),
|
|
61
61
|
...(options.questionController ? [createQuestionTool(options.questionController)] : []),
|
|
62
62
|
...(skillRegistry ? [createSkillSearchTool(skillRegistry), createSkillTool(skillRegistry)] : []),
|
|
63
63
|
...(options.todoStore ? [createTodoTool(options.todoStore)] : []),
|
package/dist/tui/run.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type Agent } from "../agent.js";
|
|
|
2
2
|
import type { CliArgs } from "../cli.js";
|
|
3
3
|
import type { ThemeMode } from "../config.js";
|
|
4
4
|
import type { ExternalHookController } from "../hooks/controller.js";
|
|
5
|
-
import
|
|
5
|
+
import { SessionManager } from "../session.js";
|
|
6
6
|
import type { PlanDecision, Provider } from "../types.js";
|
|
7
7
|
import type { ProviderRegistry } from "../provider-registry.js";
|
|
8
8
|
import type { SkillRegistry } from "../skills/registry.js";
|
|
@@ -45,5 +45,21 @@ export interface RunTuiOptions {
|
|
|
45
45
|
runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
|
|
46
46
|
/** One-line "update available" notice shown on the home screen, if any. */
|
|
47
47
|
updateNotice?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Background registry check started before the TUI. Resolves with a late
|
|
50
|
+
* "update available" notice (or null); the TUI surfaces it live — on the
|
|
51
|
+
* home screen when still there, otherwise as a composer notice.
|
|
52
|
+
*/
|
|
53
|
+
updateNoticeRefresh?: Promise<string | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Swap the active session in place (driven by the /session picker).
|
|
56
|
+
* Rebinds persistence to the picked session file and replaces the agent's
|
|
57
|
+
* message history; the TUI rebuilds its transcript from the result.
|
|
58
|
+
*/
|
|
59
|
+
switchSession?: (sessionFile: string) => {
|
|
60
|
+
manager: SessionManager;
|
|
61
|
+
} | {
|
|
62
|
+
error: string;
|
|
63
|
+
};
|
|
48
64
|
}
|
|
49
65
|
export declare function runTui(agent: Agent, args: CliArgs, options?: RunTuiOptions): Promise<void>;
|
package/dist/tui/run.js
CHANGED
|
@@ -12,6 +12,8 @@ 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";
|
|
@@ -99,6 +101,7 @@ const DEFAULT_THEME = {
|
|
|
99
101
|
toolRead: "#9d7cd8",
|
|
100
102
|
toolWrite: "#f5a742",
|
|
101
103
|
toolSearch: "#5c9cf5",
|
|
104
|
+
toolMcp: "#d479c9",
|
|
102
105
|
diffAdded: "#7fd88f",
|
|
103
106
|
diffRemoved: "#e06c75",
|
|
104
107
|
diffContext: "#a6acb8",
|
|
@@ -143,6 +146,7 @@ const LIGHT_THEME = {
|
|
|
143
146
|
toolRead: "#6F55AE",
|
|
144
147
|
toolWrite: "#8B4A00",
|
|
145
148
|
toolSearch: "#356FD2",
|
|
149
|
+
toolMcp: "#A03595",
|
|
146
150
|
diffAdded: "#1E725C",
|
|
147
151
|
diffRemoved: "#B62633",
|
|
148
152
|
diffContext: "#6F7377",
|
|
@@ -562,6 +566,9 @@ function OpenTuiApp(props) {
|
|
|
562
566
|
let rootBox;
|
|
563
567
|
let sidebarShell;
|
|
564
568
|
let homeSurfaceShell;
|
|
569
|
+
let homeUpdateNotice = props.options.updateNotice;
|
|
570
|
+
let homeUpdateNoticeBox;
|
|
571
|
+
let homeUpdateNoticeText;
|
|
565
572
|
let transcriptHost;
|
|
566
573
|
const transcriptState = {
|
|
567
574
|
entries: [],
|
|
@@ -1177,7 +1184,13 @@ function OpenTuiApp(props) {
|
|
|
1177
1184
|
promptModelLabels.delete(ref);
|
|
1178
1185
|
};
|
|
1179
1186
|
const cycleMode = () => {
|
|
1180
|
-
|
|
1187
|
+
// Mode switching is intentionally allowed while the agent is running:
|
|
1188
|
+
// Agent.setMode() is safe mid-run and the approval controller reads the
|
|
1189
|
+
// live mode on every request, so flipping to bypass (or into plan) takes
|
|
1190
|
+
// effect from the very next tool call — no need to wait for the turn to
|
|
1191
|
+
// finish. Only pickers and the plan-approval dialog still block it,
|
|
1192
|
+
// because those surfaces own the keyboard.
|
|
1193
|
+
if (picker || pendingPlan())
|
|
1181
1194
|
return false;
|
|
1182
1195
|
const next = getNextPermissionMode(props.agent.mode);
|
|
1183
1196
|
props.agent.setMode(next);
|
|
@@ -3042,7 +3055,10 @@ function OpenTuiApp(props) {
|
|
|
3042
3055
|
// "(current)" sits at the bottom of the rewind list and is the safe default.
|
|
3043
3056
|
: step === "rewind"
|
|
3044
3057
|
? Math.max(0, items.length - 1)
|
|
3045
|
-
:
|
|
3058
|
+
// Sessions: start on the most recent conversation that is not the active one.
|
|
3059
|
+
: step === "sessions"
|
|
3060
|
+
? preferredSessionPickerIndex(items)
|
|
3061
|
+
: 0,
|
|
3046
3062
|
apiKey: "",
|
|
3047
3063
|
};
|
|
3048
3064
|
activePrompt()?.clear();
|
|
@@ -3077,6 +3093,8 @@ function OpenTuiApp(props) {
|
|
|
3077
3093
|
return buildRewindPickerItems();
|
|
3078
3094
|
if (step === "rewind-action")
|
|
3079
3095
|
return buildRewindActionItems(providerId);
|
|
3096
|
+
if (step === "sessions")
|
|
3097
|
+
return buildSessionPickerItems();
|
|
3080
3098
|
if (step === "models") {
|
|
3081
3099
|
if (providerDialogModelItems?.key === modelPickerCacheKey(providerId)) {
|
|
3082
3100
|
return providerDialogModelItems.items;
|
|
@@ -3319,6 +3337,8 @@ function OpenTuiApp(props) {
|
|
|
3319
3337
|
return "Rewind — restore to the point before…";
|
|
3320
3338
|
if (state.step === "rewind-action")
|
|
3321
3339
|
return "Rewind — what to restore?";
|
|
3340
|
+
if (state.step === "sessions")
|
|
3341
|
+
return "Resume a session";
|
|
3322
3342
|
const provider = providerDisplayName(state.providerId);
|
|
3323
3343
|
if (state.step === "auth")
|
|
3324
3344
|
return `${provider} auth method`;
|
|
@@ -3343,6 +3363,8 @@ function OpenTuiApp(props) {
|
|
|
3343
3363
|
return `↑/↓ move · enter continue · esc cancel${count}`;
|
|
3344
3364
|
if (state.step === "rewind-action")
|
|
3345
3365
|
return "↑/↓ move · enter confirm · esc back";
|
|
3366
|
+
if (state.step === "sessions")
|
|
3367
|
+
return `↑/↓ move · enter resume · esc close${count}`;
|
|
3346
3368
|
const escLabel = state.step === "providers" ? "esc close" : "esc back";
|
|
3347
3369
|
return `↑/↓ move · enter select · ${escLabel}${count}`;
|
|
3348
3370
|
}
|
|
@@ -3360,7 +3382,7 @@ function OpenTuiApp(props) {
|
|
|
3360
3382
|
}
|
|
3361
3383
|
function providerDialogColumnWidths(state, panelWidth) {
|
|
3362
3384
|
const contentWidth = Math.max(24, panelWidth - PROVIDER_DIALOG_ROW_RESERVED_WIDTH);
|
|
3363
|
-
const footer = state.step === "skills" ? 10 : state.step === "providers" ? 9 : 8;
|
|
3385
|
+
const footer = state.step === "skills" || state.step === "sessions" ? 10 : state.step === "providers" ? 9 : 8;
|
|
3364
3386
|
const minLabel = state.step === "skills" ? 18 : 24;
|
|
3365
3387
|
const desiredDetail = state.step === "skills"
|
|
3366
3388
|
? 30
|
|
@@ -3370,7 +3392,9 @@ function OpenTuiApp(props) {
|
|
|
3370
3392
|
? 40
|
|
3371
3393
|
: state.step === "rewind"
|
|
3372
3394
|
? 18
|
|
3373
|
-
:
|
|
3395
|
+
: state.step === "sessions"
|
|
3396
|
+
? 14
|
|
3397
|
+
: 16;
|
|
3374
3398
|
const detail = Math.max(8, Math.min(desiredDetail, contentWidth - footer - minLabel));
|
|
3375
3399
|
const label = Math.max(8, contentWidth - detail - footer);
|
|
3376
3400
|
return { label, detail, footer };
|
|
@@ -3579,6 +3603,15 @@ function OpenTuiApp(props) {
|
|
|
3579
3603
|
openProviderDialog("rewind-action", item.value);
|
|
3580
3604
|
return;
|
|
3581
3605
|
}
|
|
3606
|
+
if (state.step === "sessions") {
|
|
3607
|
+
closeProviderDialog();
|
|
3608
|
+
if (!item.value || item.value === props.options.sessionManager?.getSessionFile()) {
|
|
3609
|
+
// Selecting the active session keeps everything as is.
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
await switchToSession(item.value);
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3582
3615
|
if (state.step === "rewind-action") {
|
|
3583
3616
|
closeProviderDialog();
|
|
3584
3617
|
await executeSlash(item.command);
|
|
@@ -4909,6 +4942,9 @@ function OpenTuiApp(props) {
|
|
|
4909
4942
|
openRewindPicker: () => {
|
|
4910
4943
|
openProviderDialog("rewind");
|
|
4911
4944
|
},
|
|
4945
|
+
openSessionPicker: () => {
|
|
4946
|
+
openProviderDialog("sessions");
|
|
4947
|
+
},
|
|
4912
4948
|
fillComposer: (text) => {
|
|
4913
4949
|
resetPromptHistoryBrowse();
|
|
4914
4950
|
setPromptText(text);
|
|
@@ -5256,6 +5292,44 @@ function OpenTuiApp(props) {
|
|
|
5256
5292
|
items.push({ label: "(current)", value: "", command: "" });
|
|
5257
5293
|
return items;
|
|
5258
5294
|
}
|
|
5295
|
+
function buildSessionPickerItems() {
|
|
5296
|
+
const activeFile = props.options.sessionManager?.getSessionFile();
|
|
5297
|
+
const summaries = SessionManager.summarizeSessionsForCwd(props.args.cwd);
|
|
5298
|
+
return buildSessionPickerEntries(summaries, activeFile).map((entry) => ({
|
|
5299
|
+
label: entry.label,
|
|
5300
|
+
detail: entry.detail,
|
|
5301
|
+
value: entry.value,
|
|
5302
|
+
command: "",
|
|
5303
|
+
footer: entry.footer,
|
|
5304
|
+
gutter: entry.gutter,
|
|
5305
|
+
}));
|
|
5306
|
+
}
|
|
5307
|
+
async function switchToSession(sessionFile) {
|
|
5308
|
+
const switchSession = props.options.switchSession;
|
|
5309
|
+
if (!switchSession) {
|
|
5310
|
+
addMessage("error", "Session switching is not available in this mode.");
|
|
5311
|
+
return;
|
|
5312
|
+
}
|
|
5313
|
+
if (isRunning()) {
|
|
5314
|
+
setNotice("Stop the current run before switching sessions.");
|
|
5315
|
+
return;
|
|
5316
|
+
}
|
|
5317
|
+
const result = switchSession(sessionFile);
|
|
5318
|
+
if ("error" in result) {
|
|
5319
|
+
addMessage("error", `Failed to switch session: ${result.error}`);
|
|
5320
|
+
return;
|
|
5321
|
+
}
|
|
5322
|
+
props.options.sessionManager = result.manager;
|
|
5323
|
+
// Same rebuild path as /rewind: the agent history was replaced wholesale,
|
|
5324
|
+
// so reconstruct the transcript from it instead of patching the display.
|
|
5325
|
+
displayMessages = reconstructDisplayMessages(props.agent.messages);
|
|
5326
|
+
streamingDisplay = undefined;
|
|
5327
|
+
redrawTranscript(undefined, displayMessages);
|
|
5328
|
+
syncTodosFromAgent();
|
|
5329
|
+
bumpSidebar();
|
|
5330
|
+
syncPromptSurfaces(true);
|
|
5331
|
+
addMessage("assistant", `⤷ Resumed session: ${sessionDisplayName(result.manager)}`);
|
|
5332
|
+
}
|
|
5259
5333
|
function buildRewindActionItems(turnNumber) {
|
|
5260
5334
|
if (!turnNumber)
|
|
5261
5335
|
return [];
|
|
@@ -5853,11 +5927,49 @@ function OpenTuiApp(props) {
|
|
|
5853
5927
|
}, [
|
|
5854
5928
|
h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...logoLines.map((line) => renderHomeLogoLine(line))),
|
|
5855
5929
|
h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center", paddingTop: 1 }, h("text", { fg: theme.textMuted, content: `v${getCurrentVersion()}` })),
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
:
|
|
5930
|
+
// Always mounted so a late registry check can reveal it mid-session.
|
|
5931
|
+
h("box", {
|
|
5932
|
+
ref: (ref) => {
|
|
5933
|
+
homeUpdateNoticeBox = ref;
|
|
5934
|
+
ref.visible = !!homeUpdateNotice;
|
|
5935
|
+
},
|
|
5936
|
+
visible: !!homeUpdateNotice,
|
|
5937
|
+
flexShrink: 0,
|
|
5938
|
+
flexDirection: "column",
|
|
5939
|
+
alignItems: "center",
|
|
5940
|
+
}, h("text", {
|
|
5941
|
+
ref: (ref) => { homeUpdateNoticeText = ref; },
|
|
5942
|
+
fg: theme.accent,
|
|
5943
|
+
content: homeUpdateNotice ?? "",
|
|
5944
|
+
})),
|
|
5859
5945
|
]);
|
|
5860
5946
|
}
|
|
5947
|
+
function watchUpdateNoticeRefresh() {
|
|
5948
|
+
const refresh = props.options.updateNoticeRefresh;
|
|
5949
|
+
if (!refresh)
|
|
5950
|
+
return;
|
|
5951
|
+
refresh.then((notice) => {
|
|
5952
|
+
if (!notice || uiDisposed)
|
|
5953
|
+
return;
|
|
5954
|
+
homeUpdateNotice = notice;
|
|
5955
|
+
if (homeUpdateNoticeText)
|
|
5956
|
+
homeUpdateNoticeText.content = notice;
|
|
5957
|
+
if (homeUpdateNoticeBox)
|
|
5958
|
+
homeUpdateNoticeBox.visible = true;
|
|
5959
|
+
// Already chatting (or resumed straight into a transcript): the home
|
|
5960
|
+
// banner is hidden, so surface the nudge as a transcript line instead.
|
|
5961
|
+
// (Not setNotice: the notice() row in renderSessionView is evaluated
|
|
5962
|
+
// once at initial render and never materializes afterwards.)
|
|
5963
|
+
if (!isHomeSurfaceActive(streamingDisplay))
|
|
5964
|
+
addMessage("assistant", notice);
|
|
5965
|
+
rootBox?.requestRender();
|
|
5966
|
+
}).catch(() => {
|
|
5967
|
+
// The check is best-effort; never disturb the session over it.
|
|
5968
|
+
});
|
|
5969
|
+
}
|
|
5970
|
+
// Component body, not onMount: the onMount callback never fires under the
|
|
5971
|
+
// current @opentui/solid runtime, so anything registered there is dead code.
|
|
5972
|
+
watchUpdateNoticeRefresh();
|
|
5861
5973
|
function renderQuestionPanelHost() {
|
|
5862
5974
|
return h("box", {
|
|
5863
5975
|
ref: (ref) => {
|
|
@@ -7795,7 +7907,7 @@ function createTraceGroupRenderable(ctx, group, syntaxStyle, width = 80) {
|
|
|
7795
7907
|
}))));
|
|
7796
7908
|
}
|
|
7797
7909
|
if (group.omitted > 0) {
|
|
7798
|
-
children.push(createText(ctx,
|
|
7910
|
+
children.push(createText(ctx, traceGroupOmittedLabel(group), {
|
|
7799
7911
|
fg: theme.textMuted,
|
|
7800
7912
|
wrapMode: "word",
|
|
7801
7913
|
}));
|
|
@@ -7813,6 +7925,15 @@ function shouldRenderTraceGroupAsRawTool(tool) {
|
|
|
7813
7925
|
function traceGroupDetailLines(group) {
|
|
7814
7926
|
return group.previewLines.length > 0 ? group.previewLines : group.items;
|
|
7815
7927
|
}
|
|
7928
|
+
// Overflow hint under a trace group. Line-based details (tool output) read as
|
|
7929
|
+
// "N more lines"; item-based details (file lists) stay as "N more".
|
|
7930
|
+
function traceGroupOmittedLabel(group) {
|
|
7931
|
+
if (group.previewLines.length > 0) {
|
|
7932
|
+
const noun = group.omitted === 1 ? "line" : "lines";
|
|
7933
|
+
return ` ... ${group.omitted} more ${noun}, Ctrl+O to expand`;
|
|
7934
|
+
}
|
|
7935
|
+
return ` ... ${group.omitted} more, Ctrl+O to expand`;
|
|
7936
|
+
}
|
|
7816
7937
|
const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
|
|
7817
7938
|
function executeInlineBudget(group, width) {
|
|
7818
7939
|
return Math.max(14, width - group.title.length - 20);
|
|
@@ -7889,9 +8010,14 @@ function traceGroupTitleColor(group) {
|
|
|
7889
8010
|
case "edit": return theme.toolWrite;
|
|
7890
8011
|
case "subagent": return theme.accent;
|
|
7891
8012
|
case "list": return theme.secondary;
|
|
7892
|
-
default: return theme.toolText;
|
|
8013
|
+
default: return isMcpTraceGroup(group) ? theme.toolMcp : theme.toolText;
|
|
7893
8014
|
}
|
|
7894
8015
|
}
|
|
8016
|
+
// An "other" group whose single tool is an MCP call (`mcp__<server>__<tool>`).
|
|
8017
|
+
function isMcpTraceGroup(group) {
|
|
8018
|
+
const name = group.raw[0]?.name;
|
|
8019
|
+
return typeof name === "string" && name.startsWith("mcp__");
|
|
8020
|
+
}
|
|
7895
8021
|
function traceGroupKey(group) {
|
|
7896
8022
|
return `group:${group.kind}:${group.raw.map((tool) => tool.id).join(":")}`;
|
|
7897
8023
|
}
|
|
@@ -8680,7 +8806,7 @@ function renderTraceGroup(group, syntaxStyle, width = 80) {
|
|
|
8680
8806
|
wrapMode: "word",
|
|
8681
8807
|
}, `${index === 0 ? "↳ " : " "}${truncate(line, detailWidth)}`)))
|
|
8682
8808
|
: null, group.omitted > 0
|
|
8683
|
-
? h("text", { fg: theme.textMuted, wrapMode: "word" },
|
|
8809
|
+
? h("text", { fg: theme.textMuted, wrapMode: "word" }, traceGroupOmittedLabel(group))
|
|
8684
8810
|
: null);
|
|
8685
8811
|
}
|
|
8686
8812
|
function renderTool(tool, syntaxStyle, width = 80) {
|
|
@@ -9340,6 +9466,17 @@ function getApprovalPanelMeta(request) {
|
|
|
9340
9466
|
path: request.path,
|
|
9341
9467
|
};
|
|
9342
9468
|
}
|
|
9469
|
+
if (request.type === "agent_profile") {
|
|
9470
|
+
return {
|
|
9471
|
+
icon: "@",
|
|
9472
|
+
title: `Trust project agent profile "${request.name}"`,
|
|
9473
|
+
subtitle: "from .bubble/agents — its prompt will drive a subagent",
|
|
9474
|
+
preview: `${shortCwd(request.path)}\n${request.promptPreview}`,
|
|
9475
|
+
previewHeight: 8,
|
|
9476
|
+
previewColor: theme.toolText,
|
|
9477
|
+
path: request.path,
|
|
9478
|
+
};
|
|
9479
|
+
}
|
|
9343
9480
|
const path = shortCwd(request.path);
|
|
9344
9481
|
if (request.type === "edit") {
|
|
9345
9482
|
return {
|
|
@@ -9454,6 +9591,8 @@ function displayToolName(name) {
|
|
|
9454
9591
|
wait_agent: "WaitAgent",
|
|
9455
9592
|
send_input: "SendInput",
|
|
9456
9593
|
close_agent: "CloseAgent",
|
|
9594
|
+
list_agents: "ListAgents",
|
|
9595
|
+
agent_team: "AgentTeam",
|
|
9457
9596
|
task: "Task",
|
|
9458
9597
|
todo: "Todo",
|
|
9459
9598
|
question: "Questions",
|
|
@@ -9476,6 +9615,12 @@ function toolHeader(tool) {
|
|
|
9476
9615
|
const agentId = args.agent_id ?? (Array.isArray(args.agent_ids) ? `${args.agent_ids.length} agents` : undefined);
|
|
9477
9616
|
return agentId ? `(${truncate(String(agentId), 64)})` : "";
|
|
9478
9617
|
}
|
|
9618
|
+
if (tool.name === "agent_team") {
|
|
9619
|
+
const items = Array.isArray(args.items) ? `${args.items.length} items` : "";
|
|
9620
|
+
const description = typeof args.description === "string" ? args.description : "";
|
|
9621
|
+
const label = [description, items].filter(Boolean).join(", ");
|
|
9622
|
+
return label ? `(${truncate(label, 64)})` : "";
|
|
9623
|
+
}
|
|
9479
9624
|
const value = args.path ?? args.command ?? args.pattern ?? args.url ?? args.query ?? toolPath(tool);
|
|
9480
9625
|
return value ? `(${truncate(String(value).replace(/\n/g, " "), 64)})` : "";
|
|
9481
9626
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SessionSummary } from "../session.js";
|
|
2
|
+
export interface SessionPickerEntry {
|
|
3
|
+
/** Session title (or first-message preview when untitled). */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Message count, e.g. "12 messages". */
|
|
6
|
+
detail: string;
|
|
7
|
+
/** Absolute path to the session .jsonl file. */
|
|
8
|
+
value: string;
|
|
9
|
+
/** "current" for the active session, otherwise a relative timestamp. */
|
|
10
|
+
footer: string;
|
|
11
|
+
/** "●" marks the active session. */
|
|
12
|
+
gutter?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildSessionPickerEntries(summaries: SessionSummary[], activeFile: string | undefined, now?: number): SessionPickerEntry[];
|
|
15
|
+
/** Default selection: the most recent session that is not the active one. */
|
|
16
|
+
export declare function preferredSessionPickerIndex(entries: Array<{
|
|
17
|
+
gutter?: string;
|
|
18
|
+
}>): number;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { normalizeSingleLine, truncateVisual } from "../text-display.js";
|
|
2
|
+
import { formatRelativeTime } from "./recent-activity.js";
|
|
3
|
+
const SESSION_PICKER_LABEL_MAX_WIDTH = 72;
|
|
4
|
+
export function buildSessionPickerEntries(summaries, activeFile, now = Date.now()) {
|
|
5
|
+
return summaries.map((summary) => {
|
|
6
|
+
const isCurrent = summary.file === activeFile;
|
|
7
|
+
const label = truncateVisual(normalizeSingleLine(summary.title || summary.preview || summary.name), SESSION_PICKER_LABEL_MAX_WIDTH) || summary.name;
|
|
8
|
+
return {
|
|
9
|
+
label,
|
|
10
|
+
detail: `${summary.messageCount} message${summary.messageCount === 1 ? "" : "s"}`,
|
|
11
|
+
value: summary.file,
|
|
12
|
+
footer: isCurrent ? "current" : formatRelativeTime(summary.mtime, now),
|
|
13
|
+
gutter: isCurrent ? "●" : undefined,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Default selection: the most recent session that is not the active one. */
|
|
18
|
+
export function preferredSessionPickerIndex(entries) {
|
|
19
|
+
const firstOther = entries.findIndex((entry) => entry.gutter !== "●");
|
|
20
|
+
return firstOther >= 0 ? firstOther : 0;
|
|
21
|
+
}
|
package/dist/tui/trace-groups.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { getEditDiffDetails } from "./edit-diff.js";
|
|
3
3
|
import { formatSubagentRoute } from "../agent/subagent-route-format.js";
|
|
4
|
+
import { mcpInfoFromString } from "../mcp/name.js";
|
|
4
5
|
const DEFAULT_MAX_ITEMS = 6;
|
|
5
6
|
const DEFAULT_MAX_PREVIEW_LINES = 8;
|
|
6
7
|
export function buildTraceGroups(toolCalls, options = {}) {
|
|
@@ -120,13 +121,18 @@ function classifyTool(toolCall) {
|
|
|
120
121
|
return { kind: "edit", title: "Edit", bucketKey: `edit:${toolCall.id}`, groupable: false };
|
|
121
122
|
case "write":
|
|
122
123
|
return { kind: "write", title: "Write", bucketKey: "write", groupable: true };
|
|
123
|
-
default:
|
|
124
|
+
default: {
|
|
125
|
+
const mcp = mcpInfoFromString(toolCall.name);
|
|
126
|
+
const title = mcp
|
|
127
|
+
? `${mcp.serverName.toUpperCase()}: ${mcp.toolName}`
|
|
128
|
+
: displayToolName(toolCall.name);
|
|
124
129
|
return {
|
|
125
130
|
kind: "other",
|
|
126
|
-
title
|
|
131
|
+
title,
|
|
127
132
|
bucketKey: `${toolCall.name}:${toolCall.id}`,
|
|
128
133
|
groupable: false,
|
|
129
134
|
};
|
|
135
|
+
}
|
|
130
136
|
}
|
|
131
137
|
}
|
|
132
138
|
function buildTraceGroup(classifier, raw, options) {
|
|
@@ -345,15 +351,23 @@ function buildSubagentGroup(classifier, tool, options, pending, startedAt) {
|
|
|
345
351
|
}
|
|
346
352
|
function buildOtherGroup(classifier, raw, options, pending, startedAt, hasError, errorCount) {
|
|
347
353
|
const tool = raw[0];
|
|
348
|
-
const
|
|
354
|
+
const mcp = mcpInfoFromString(tool.name);
|
|
355
|
+
// MCP tools carry arbitrary args, so render them as `key: value` pairs inline
|
|
356
|
+
// (via the `command` slot) instead of the path-based header used for builtins.
|
|
357
|
+
const header = mcp ? undefined : toolHeader(tool, options.homeDir);
|
|
358
|
+
const argsLabel = mcp ? mcpArgsLabel(tool.args) : "";
|
|
359
|
+
// Suppress the "N calls" fallback for MCP tools — the title already names the
|
|
360
|
+
// tool, and args (when present) ride alongside it.
|
|
361
|
+
const hasInline = mcp || !!header;
|
|
349
362
|
const preview = resultLines(tool.result).map((line) => formatTracePath(line, options.homeDir));
|
|
350
363
|
const { shown, omitted } = take(preview, options.maxPreviewLines);
|
|
351
364
|
return {
|
|
352
365
|
kind: "other",
|
|
353
366
|
title: classifier.title,
|
|
354
367
|
raw,
|
|
355
|
-
|
|
356
|
-
|
|
368
|
+
command: argsLabel || undefined,
|
|
369
|
+
count: hasInline ? undefined : raw.length,
|
|
370
|
+
noun: hasInline ? undefined : plural(raw.length, "call", "calls"),
|
|
357
371
|
items: header ? [header] : [],
|
|
358
372
|
previewLines: shown,
|
|
359
373
|
errorLines: [],
|
|
@@ -469,6 +483,28 @@ function displayToolName(name) {
|
|
|
469
483
|
return "Tool";
|
|
470
484
|
return name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " ");
|
|
471
485
|
}
|
|
486
|
+
/** Compact `key: value, key: value` rendering of an MCP tool's arguments. */
|
|
487
|
+
function mcpArgsLabel(args) {
|
|
488
|
+
if (!args || typeof args !== "object")
|
|
489
|
+
return "";
|
|
490
|
+
return Object.entries(args)
|
|
491
|
+
.filter(([, value]) => value !== undefined)
|
|
492
|
+
.map(([key, value]) => `${key}: ${formatMcpArgValue(value)}`)
|
|
493
|
+
.join(", ");
|
|
494
|
+
}
|
|
495
|
+
function formatMcpArgValue(value) {
|
|
496
|
+
if (typeof value === "string")
|
|
497
|
+
return JSON.stringify(value);
|
|
498
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
|
499
|
+
return String(value);
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
return JSON.stringify(value);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return String(value);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
472
508
|
function toolHeader(tool, homeDir) {
|
|
473
509
|
const args = tool.args || {};
|
|
474
510
|
for (const key of ["path", "command", "pattern", "query", "url"]) {
|
package/dist/tui/wordmark.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface BubbleWordmarkLine {
|
|
|
8
8
|
tone?: BubbleWordmarkTone;
|
|
9
9
|
segments?: BubbleWordmarkSegment[];
|
|
10
10
|
}
|
|
11
|
+
export declare const BUBBLE_CAT: BubbleWordmarkLine[];
|
|
12
|
+
export declare const BUBBLE_CAT_LARGE: BubbleWordmarkLine[];
|
|
11
13
|
export declare const BUBBLE_WORDMARK: BubbleWordmarkLine[];
|
|
12
14
|
export declare const BUBBLE_WORDMARK_LARGE: BubbleWordmarkLine[];
|
|
13
15
|
export declare const BUBBLE_COMPACT_WORDMARK: BubbleWordmarkLine[];
|
package/dist/tui/wordmark.js
CHANGED
|
@@ -22,6 +22,27 @@ const LOWER_B = {
|
|
|
22
22
|
" ",
|
|
23
23
|
],
|
|
24
24
|
};
|
|
25
|
+
// Pixel cat mascot, drawn on the same half-block pixel grid as the letters:
|
|
26
|
+
// pointy ears, 2x2-pixel eyes, tiny mouth, round chin (10x14 pixels). It is
|
|
27
|
+
// stacked above the wordmark (icon-over-name lockup) rather than inlined, so
|
|
28
|
+
// its solid fill doesn't compete with the thin letter strokes.
|
|
29
|
+
const CAT_LINES = [
|
|
30
|
+
" █▄ ▄█ ",
|
|
31
|
+
" ███▄▄███ ",
|
|
32
|
+
"██████████",
|
|
33
|
+
"█ ████ █",
|
|
34
|
+
"████▀▀████",
|
|
35
|
+
"██████████",
|
|
36
|
+
" ▀██████▀ ",
|
|
37
|
+
];
|
|
38
|
+
export const BUBBLE_CAT = CAT_LINES.map((text) => ({
|
|
39
|
+
text,
|
|
40
|
+
tone: "brand",
|
|
41
|
+
}));
|
|
42
|
+
export const BUBBLE_CAT_LARGE = CAT_LINES.map((text) => ({
|
|
43
|
+
text: text.split("").map((ch) => ch + ch).join(""),
|
|
44
|
+
tone: "brand",
|
|
45
|
+
}));
|
|
25
46
|
const GLYPHS = {
|
|
26
47
|
u: {
|
|
27
48
|
tone: "ink",
|
|
@@ -172,10 +193,16 @@ export function bubbleWordmarkLineText(line) {
|
|
|
172
193
|
export function bubbleWordmarkMaxWidth(lines = BUBBLE_WORDMARK) {
|
|
173
194
|
return Math.max(...lines.map((line) => bubbleWordmarkLineText(line).length));
|
|
174
195
|
}
|
|
196
|
+
const LOGO_GAP = { text: "", tone: "caption" };
|
|
197
|
+
// Icon-over-name lockup: pixel cat centered above the wordmark. Both render
|
|
198
|
+
// sites center every line independently, which is what stacks the cat over
|
|
199
|
+
// the text without any per-line padding here.
|
|
175
200
|
export function bubbleWordmarkForWidth(width) {
|
|
176
|
-
if (width >= bubbleWordmarkMaxWidth(BUBBLE_WORDMARK_LARGE) + 4)
|
|
177
|
-
return BUBBLE_WORDMARK_LARGE;
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
if (width >= bubbleWordmarkMaxWidth(BUBBLE_WORDMARK_LARGE) + 4) {
|
|
202
|
+
return [...BUBBLE_CAT_LARGE, LOGO_GAP, ...BUBBLE_WORDMARK_LARGE];
|
|
203
|
+
}
|
|
204
|
+
if (width >= bubbleWordmarkMaxWidth() + 4) {
|
|
205
|
+
return [...BUBBLE_CAT, LOGO_GAP, ...BUBBLE_WORDMARK];
|
|
206
|
+
}
|
|
180
207
|
return BUBBLE_COMPACT_WORDMARK;
|
|
181
208
|
}
|