@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.
Files changed (65) hide show
  1. package/README.md +197 -34
  2. package/dist/agent/abort-errors.d.ts +14 -0
  3. package/dist/agent/abort-errors.js +21 -0
  4. package/dist/agent/budget-ledger.d.ts +41 -0
  5. package/dist/agent/budget-ledger.js +64 -0
  6. package/dist/agent/child-runner.d.ts +55 -0
  7. package/dist/agent/child-runner.js +312 -0
  8. package/dist/agent/internal-reminder-sanitizer.js +29 -9
  9. package/dist/agent/profiles.d.ts +8 -0
  10. package/dist/agent/profiles.js +27 -5
  11. package/dist/agent/result-integrator.d.ts +22 -0
  12. package/dist/agent/result-integrator.js +50 -0
  13. package/dist/agent/subagent-control.d.ts +31 -0
  14. package/dist/agent/subagent-control.js +27 -0
  15. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  16. package/dist/agent/subagent-scheduler.d.ts +95 -0
  17. package/dist/agent/subagent-scheduler.js +256 -0
  18. package/dist/agent/subagent-store.d.ts +41 -0
  19. package/dist/agent/subagent-store.js +149 -0
  20. package/dist/agent/subagent-summary.d.ts +30 -0
  21. package/dist/agent/subagent-summary.js +74 -0
  22. package/dist/agent/worktree.d.ts +29 -0
  23. package/dist/agent/worktree.js +73 -0
  24. package/dist/agent.d.ts +63 -5
  25. package/dist/agent.js +360 -287
  26. package/dist/approval/controller.js +9 -1
  27. package/dist/approval/tool-helper.js +2 -0
  28. package/dist/approval/types.d.ts +17 -1
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.js +17 -0
  31. package/dist/feishu/agent-host/approval-card.js +9 -0
  32. package/dist/feishu/agent-host/run-driver.js +1 -0
  33. package/dist/main.js +38 -2
  34. package/dist/model-catalog.js +6 -0
  35. package/dist/network/errors.d.ts +28 -0
  36. package/dist/network/errors.js +24 -0
  37. package/dist/orchestrator/default-hooks.js +5 -1
  38. package/dist/prompt/compose.js +3 -0
  39. package/dist/prompt/delegation.d.ts +14 -0
  40. package/dist/prompt/delegation.js +64 -0
  41. package/dist/prompt/task-reminders.d.ts +5 -1
  42. package/dist/prompt/task-reminders.js +10 -2
  43. package/dist/provider-anthropic.js +23 -0
  44. package/dist/provider-transform.js +14 -0
  45. package/dist/provider.js +23 -3
  46. package/dist/slash-commands/commands.js +29 -2
  47. package/dist/slash-commands/types.d.ts +2 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/child-tools.d.ts +31 -0
  51. package/dist/tools/child-tools.js +106 -0
  52. package/dist/tools/index.js +1 -1
  53. package/dist/tui/run.d.ts +17 -1
  54. package/dist/tui/run.js +155 -10
  55. package/dist/tui/session-picker-data.d.ts +18 -0
  56. package/dist/tui/session-picker-data.js +21 -0
  57. package/dist/tui/trace-groups.js +41 -5
  58. package/dist/tui/wordmark.d.ts +2 -0
  59. package/dist/tui/wordmark.js +31 -4
  60. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  61. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  62. package/dist/types.d.ts +17 -0
  63. package/dist/update/index.d.ts +18 -4
  64. package/dist/update/index.js +41 -19
  65. 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
+ }
@@ -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 type { SessionManager } from "../session.js";
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
- if (picker || pendingPlan() || isRunning())
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
- : 0,
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
- : 16;
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
- ...(props.options.updateNotice
5857
- ? [h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, h("text", { fg: theme.accent, content: props.options.updateNotice }))]
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, ` ... ${group.omitted} more, Ctrl+O to view`, {
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" }, ` ... ${group.omitted} more, Ctrl+O to view`)
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
+ }
@@ -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: displayToolName(toolCall.name),
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 header = toolHeader(tool, options.homeDir);
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
- count: header ? undefined : raw.length,
356
- noun: header ? undefined : plural(raw.length, "call", "calls"),
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"]) {
@@ -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[];
@@ -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
- if (width >= bubbleWordmarkMaxWidth() + 4)
179
- return BUBBLE_WORDMARK;
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
  }