@bubblebrain-ai/bubble 0.0.8 → 0.0.9

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 (74) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +12 -0
  14. package/dist/agent.js +152 -13
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -3
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.js +9 -9
  29. package/dist/main.js +43 -6
  30. package/dist/model-catalog.d.ts +9 -0
  31. package/dist/model-catalog.js +16 -0
  32. package/dist/orchestrator/default-hooks.js +18 -0
  33. package/dist/provider-openai-codex.d.ts +13 -2
  34. package/dist/provider-openai-codex.js +81 -32
  35. package/dist/provider-registry.js +20 -4
  36. package/dist/slash-commands/commands.js +24 -0
  37. package/dist/slash-commands/types.d.ts +7 -0
  38. package/dist/tools/agent-lifecycle.js +22 -4
  39. package/dist/tools/edit.js +2 -2
  40. package/dist/tools/glob.js +2 -1
  41. package/dist/tools/grep.js +2 -2
  42. package/dist/tools/lsp.js +2 -2
  43. package/dist/tools/path-utils.d.ts +2 -0
  44. package/dist/tools/path-utils.js +16 -0
  45. package/dist/tools/read.js +117 -5
  46. package/dist/tools/write.js +3 -2
  47. package/dist/tui-ink/app.d.ts +11 -2
  48. package/dist/tui-ink/app.js +191 -78
  49. package/dist/tui-ink/approval/approval-dialog.js +4 -1
  50. package/dist/tui-ink/approval/diff-view.js +2 -1
  51. package/dist/tui-ink/approval/select.js +2 -1
  52. package/dist/tui-ink/code-highlight.d.ts +2 -0
  53. package/dist/tui-ink/code-highlight.js +30 -2
  54. package/dist/tui-ink/detect-theme.d.ts +19 -0
  55. package/dist/tui-ink/detect-theme.js +123 -0
  56. package/dist/tui-ink/footer.js +4 -3
  57. package/dist/tui-ink/input-box.js +83 -26
  58. package/dist/tui-ink/input-history.d.ts +16 -0
  59. package/dist/tui-ink/input-history.js +81 -0
  60. package/dist/tui-ink/markdown.js +30 -20
  61. package/dist/tui-ink/message-list.js +112 -16
  62. package/dist/tui-ink/model-picker.js +6 -1
  63. package/dist/tui-ink/plan-confirm.js +2 -1
  64. package/dist/tui-ink/question-dialog.js +2 -1
  65. package/dist/tui-ink/run.d.ts +5 -1
  66. package/dist/tui-ink/run.js +30 -2
  67. package/dist/tui-ink/theme.d.ts +64 -35
  68. package/dist/tui-ink/theme.js +81 -8
  69. package/dist/tui-ink/todos.js +5 -3
  70. package/dist/tui-ink/trace-groups.d.ts +3 -1
  71. package/dist/tui-ink/trace-groups.js +93 -14
  72. package/dist/tui-ink/welcome.js +23 -4
  73. package/dist/types.d.ts +6 -0
  74. package/package.json +2 -1
@@ -2,9 +2,10 @@
2
2
  * Read tool - read file contents with truncation, dedup, and auto-pagination.
3
3
  */
4
4
  import { constants } from "node:fs";
5
- import { access, readFile, stat } from "node:fs/promises";
6
- import { resolve } from "node:path";
5
+ import { access, readFile, readdir, stat } from "node:fs/promises";
6
+ import { basename, dirname, extname, join, relative } from "node:path";
7
7
  import { isSensitivePath } from "./sensitive-paths.js";
8
+ import { resolveToolPath } from "./path-utils.js";
8
9
  const MAX_LINES = 2500;
9
10
  const MAX_BYTES = 256 * 1024;
10
11
  const FILE_UNCHANGED_STUB = "File unchanged since last read. The earlier read tool_result in this conversation is still current — refer to that instead of re-reading. If you need a different range, call read again with explicit offset/limit; if the file has actually changed, edit or write will refresh this cache automatically.";
@@ -33,7 +34,7 @@ export function createReadTool(cwd, approval, lsp, fileState) {
33
34
  required: ["path"],
34
35
  },
35
36
  async execute(args) {
36
- const filePath = resolve(cwd, args.path);
37
+ const filePath = resolveToolPath(cwd, args.path);
37
38
  if (isSensitivePath(filePath)) {
38
39
  return {
39
40
  content: `Error: Access to sensitive credential storage is blocked: ${filePath}`,
@@ -58,8 +59,11 @@ export function createReadTool(cwd, approval, lsp, fileState) {
58
59
  try {
59
60
  await access(filePath, constants.R_OK);
60
61
  }
61
- catch {
62
- return { content: `Error: Cannot read file: ${filePath}`, isError: true };
62
+ catch (error) {
63
+ return {
64
+ content: await readFileNotFoundMessage(filePath, cwd, error),
65
+ isError: true,
66
+ };
63
67
  }
64
68
  const argOffset = typeof args.offset === "number" ? args.offset : undefined;
65
69
  const argLimit = typeof args.limit === "number" ? args.limit : undefined;
@@ -167,3 +171,111 @@ export function createReadTool(cwd, approval, lsp, fileState) {
167
171
  },
168
172
  };
169
173
  }
174
+ async function readFileNotFoundMessage(filePath, cwd, error) {
175
+ const message = [`Error: Cannot read file: ${filePath}`];
176
+ const code = typeof error?.code === "string" ? error.code : undefined;
177
+ if (code && code !== "ENOENT" && code !== "ENOTDIR")
178
+ return message[0];
179
+ const suggestions = await suggestReadPaths(filePath, cwd);
180
+ if (suggestions.length === 1) {
181
+ message.push(`Did you mean ${suggestions[0]}?`);
182
+ }
183
+ else if (suggestions.length > 1) {
184
+ message.push("Did you mean one of these?");
185
+ message.push(...suggestions.map((suggestion) => `- ${suggestion}`));
186
+ }
187
+ return message.join("\n");
188
+ }
189
+ async function suggestReadPaths(filePath, cwd) {
190
+ const suggestions = new Set();
191
+ const underCwd = await suggestPathUnderCwd(filePath, cwd);
192
+ if (underCwd)
193
+ suggestions.add(underCwd);
194
+ for (const suggestion of await suggestSimilarFiles(filePath)) {
195
+ suggestions.add(suggestion);
196
+ }
197
+ return [...suggestions].slice(0, 5);
198
+ }
199
+ async function suggestPathUnderCwd(filePath, cwd) {
200
+ const parent = dirname(cwd);
201
+ const parentPrefix = parent.endsWith("/") ? parent : `${parent}/`;
202
+ if (!filePath.startsWith(parentPrefix) || filePath === cwd || filePath.startsWith(`${cwd}/`)) {
203
+ return undefined;
204
+ }
205
+ const candidate = join(cwd, relative(parent, filePath));
206
+ try {
207
+ const stats = await stat(candidate);
208
+ return stats.isFile() ? candidate : undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ async function suggestSimilarFiles(filePath) {
215
+ const dir = dirname(filePath);
216
+ const target = basename(filePath);
217
+ let entries;
218
+ try {
219
+ entries = await readdir(dir, { withFileTypes: true });
220
+ }
221
+ catch {
222
+ return [];
223
+ }
224
+ return entries
225
+ .filter((entry) => entry.isFile() || entry.isSymbolicLink())
226
+ .map((entry) => {
227
+ const score = similarFileScore(target, entry.name);
228
+ return score === undefined ? undefined : { path: join(dir, entry.name), score };
229
+ })
230
+ .filter((entry) => entry !== undefined)
231
+ .sort((a, b) => a.score - b.score || a.path.length - b.path.length || a.path.localeCompare(b.path))
232
+ .map((entry) => entry.path)
233
+ .slice(0, 5);
234
+ }
235
+ function similarFileScore(target, candidate) {
236
+ if (candidate === target)
237
+ return undefined;
238
+ const targetExt = extname(target).toLowerCase();
239
+ const candidateExt = extname(candidate).toLowerCase();
240
+ const targetStem = basename(target, targetExt).toLowerCase();
241
+ const candidateStem = basename(candidate, candidateExt).toLowerCase();
242
+ if (!targetStem || !candidateStem)
243
+ return undefined;
244
+ if (candidateExt === targetExt &&
245
+ (candidateStem.startsWith(`${targetStem}_`) || candidateStem.startsWith(`${targetStem}-`))) {
246
+ return 0;
247
+ }
248
+ if (candidateExt === targetExt && (candidateStem.startsWith(targetStem) || targetStem.startsWith(candidateStem))) {
249
+ return 5;
250
+ }
251
+ if (candidateStem === targetStem) {
252
+ return 10;
253
+ }
254
+ if (candidateStem.includes(targetStem) || targetStem.includes(candidateStem)) {
255
+ return candidateExt === targetExt ? 15 : 20;
256
+ }
257
+ const distance = levenshteinDistance(targetStem, candidateStem, 3);
258
+ if (distance <= 2) {
259
+ return (candidateExt === targetExt ? 30 : 35) + distance;
260
+ }
261
+ return undefined;
262
+ }
263
+ function levenshteinDistance(a, b, maxDistance) {
264
+ if (Math.abs(a.length - b.length) > maxDistance)
265
+ return maxDistance + 1;
266
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
267
+ for (let i = 1; i <= a.length; i++) {
268
+ const current = [i];
269
+ let rowMin = current[0];
270
+ for (let j = 1; j <= b.length; j++) {
271
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
272
+ const value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
273
+ current[j] = value;
274
+ rowMin = Math.min(rowMin, value);
275
+ }
276
+ if (rowMin > maxDistance)
277
+ return maxDistance + 1;
278
+ previous = current;
279
+ }
280
+ return previous[b.length];
281
+ }
@@ -2,12 +2,13 @@
2
2
  * Write tool - create files or safely replace full file contents.
3
3
  */
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
- import { dirname, resolve } from "node:path";
5
+ import { dirname } from "node:path";
6
6
  import { createTwoFilesPatch } from "diff";
7
7
  import { gateToolAction } from "../approval/tool-helper.js";
8
8
  import { formatDiagnosticBlocks } from "../lsp/index.js";
9
9
  import { isWithinWorkspace } from "./file-state.js";
10
10
  import { withFileMutationQueue } from "./file-mutation-queue.js";
11
+ import { resolveToolPath } from "./path-utils.js";
11
12
  export function createWriteTool(cwd, options = {}, approval, lsp, fileState) {
12
13
  return {
13
14
  name: "write",
@@ -27,7 +28,7 @@ export function createWriteTool(cwd, options = {}, approval, lsp, fileState) {
27
28
  required: ["path", "content"],
28
29
  },
29
30
  async execute(args) {
30
- const filePath = resolve(cwd, args.path);
31
+ const filePath = resolveToolPath(cwd, args.path);
31
32
  const overwrite = args.overwrite === true;
32
33
  if (!isWithinWorkspace(cwd, filePath)) {
33
34
  return {
@@ -2,6 +2,7 @@ import { type Agent } from "../agent.js";
2
2
  import type { CliArgs } from "../cli.js";
3
3
  import type { SessionManager } from "../session.js";
4
4
  import type { PlanDecision, Provider } from "../types.js";
5
+ import { type ResolvedTheme, type ThemeMode } from "./theme.js";
5
6
  import { ProviderRegistry } from "../provider-registry.js";
6
7
  import { SkillRegistry } from "../skills/registry.js";
7
8
  import type { ApprovalDecision, ApprovalRequest } from "../approval/types.js";
@@ -31,13 +32,21 @@ interface AppProps {
31
32
  settingsManager?: SettingsManager;
32
33
  lspService?: LspService;
33
34
  mcpManager?: McpManager;
35
+ themeMode?: ThemeMode;
36
+ themeOverrides?: Record<string, string>;
37
+ detectedTheme?: ResolvedTheme;
38
+ onThemeModeChange?: (mode: ThemeMode) => void;
34
39
  flushMemory?: () => Promise<void>;
35
40
  runMemoryCompaction?: () => Promise<string>;
36
41
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
37
42
  runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
38
43
  /** Whether the bypassPermissions mode is reachable via Shift+Tab cycling. */
39
44
  bypassEnabled?: boolean;
40
- onExit?: () => void;
45
+ onExit?: (summary: ExitSummary) => void;
41
46
  }
42
- export declare function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }: AppProps): import("react/jsx-runtime").JSX.Element;
47
+ export interface ExitSummary {
48
+ /** Wall-clock duration of the session, in milliseconds. */
49
+ wallMs: number;
50
+ }
51
+ export declare function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }: AppProps): import("react/jsx-runtime").JSX.Element;
43
52
  export {};
@@ -7,7 +7,7 @@ import { UserConfig, maskKey } from "../config.js";
7
7
  import { InputBox } from "./input-box.js";
8
8
  import { MessageList } from "./message-list.js";
9
9
  import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, nextDisplayMessageKey, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
10
- import { theme } from "./theme.js";
10
+ import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
11
11
  import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
12
12
  import { BUILTIN_PROVIDERS, ProviderRegistry, displayModel, isUserVisibleProvider } from "../provider-registry.js";
13
13
  import { buildSystemPrompt } from "../system-prompt.js";
@@ -136,6 +136,33 @@ function parsePartialArgs(buffer, previous) {
136
136
  }
137
137
  return result;
138
138
  }
139
+ function mergeToolMetadata(current, incoming) {
140
+ if (!incoming)
141
+ return current;
142
+ if (current?.kind !== "subagent" || incoming.kind !== "subagent") {
143
+ return incoming;
144
+ }
145
+ const currentSubagents = Array.isArray(current.subagents) ? current.subagents : [];
146
+ const incomingSubagents = Array.isArray(incoming.subagents) ? incoming.subagents : [];
147
+ const byId = new Map();
148
+ for (const item of currentSubagents) {
149
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
150
+ ? String(item.subAgentId)
151
+ : "";
152
+ byId.set(subAgentId || `current:${byId.size}`, item);
153
+ }
154
+ for (const item of incomingSubagents) {
155
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
156
+ ? String(item.subAgentId)
157
+ : "";
158
+ byId.set(subAgentId || `incoming:${byId.size}`, item);
159
+ }
160
+ return {
161
+ ...current,
162
+ ...incoming,
163
+ subagents: [...byId.values()],
164
+ };
165
+ }
139
166
  /**
140
167
  * Coerce a freshly-constructed DisplayMessage into one that carries a stable
141
168
  * `key`. Centralizes the safety net so callers don't have to remember to call
@@ -147,9 +174,25 @@ function withMessageKey(message) {
147
174
  const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
148
175
  return { ...message, key: nextDisplayMessageKey(prefix) };
149
176
  }
150
- export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }) {
177
+ export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }) {
178
+ const [themeMode, setThemeMode] = useState(initialThemeMode ?? "auto");
179
+ // `detectedTheme` is captured once at startup in main.ts. We keep it in state
180
+ // so future re-detection (e.g. if a user runs `/theme auto` after switching
181
+ // their terminal) is possible without re-mounting the app. For now it never
182
+ // changes after first render.
183
+ const [autoResolved] = useState(detectedTheme ?? "dark");
184
+ const palette = useMemo(() => {
185
+ const resolved = themeMode === "auto" ? autoResolved : themeMode;
186
+ return paletteFor(resolved, themeOverrides);
187
+ }, [themeMode, autoResolved, themeOverrides]);
188
+ const applyThemeMode = useCallback((mode) => {
189
+ setThemeMode(mode);
190
+ onThemeModeChange?.(mode);
191
+ }, [onThemeModeChange]);
192
+ const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
151
193
  const { exit } = useApp();
152
194
  const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
195
+ const [clearEpoch, setClearEpoch] = useState(0);
153
196
  const [isRunning, setIsRunning] = useState(false);
154
197
  const [streamingContent, setStreamingContent] = useState("");
155
198
  const [streamingReasoning, setStreamingReasoning] = useState("");
@@ -167,8 +210,34 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
167
210
  const [verboseTrace, setVerboseTrace] = useState(false);
168
211
  const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
169
212
  const { columns: terminalColumns } = useTerminalSize();
213
+ // When the terminal width changes mid-session (e.g. the user toggles an IDE
214
+ // side-panel), every full-width ANSI bg run already written into scrollback
215
+ // by <Static> stays at the old width. The terminal then wraps those rows on
216
+ // the new width and leaves residual coloured stripes underneath. Ink can't
217
+ // reach scrollback to repaint. So on width change, we wipe screen +
218
+ // scrollback and bump `clearEpoch` so <Static> remounts and replays every
219
+ // committed message at the new width. Cost: a single flicker per resize and
220
+ // any pre-session shell scrollback is also cleared. Skip the initial mount.
221
+ const previousColumnsRef = useRef(null);
222
+ useEffect(() => {
223
+ if (previousColumnsRef.current === null) {
224
+ previousColumnsRef.current = terminalColumns;
225
+ return;
226
+ }
227
+ if (previousColumnsRef.current === terminalColumns)
228
+ return;
229
+ previousColumnsRef.current = terminalColumns;
230
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
231
+ setClearEpoch((n) => n + 1);
232
+ }, [terminalColumns]);
170
233
  const activeAbortRef = useRef(null);
171
234
  const exitRequestedRef = useRef(false);
235
+ const sessionStartRef = useRef(Date.now());
236
+ // Set true the moment /quit is invoked so we can hide dynamic UI (composer,
237
+ // waiting indicator, footer) before Ink snapshots its final frame into the
238
+ // shell scrollback. Without this, the last visible "> " input row stays
239
+ // glued to the bottom of the terminal after exit.
240
+ const [isExiting, setIsExiting] = useState(false);
172
241
  // 1Hz tick used to refresh elapsed counters on in-progress tool rows and
173
242
  // on the WaitingIndicator. Only ticks while the agent is running so we
174
243
  // don't churn renders at idle.
@@ -194,6 +263,10 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
194
263
  if (exitRequestedRef.current)
195
264
  return;
196
265
  exitRequestedRef.current = true;
266
+ // Drop the composer / waiting indicator / footer from the React tree
267
+ // *before* we tell Ink to exit, so Ink's final log-update snapshot
268
+ // doesn't leave an empty "> " row behind in the shell scrollback.
269
+ setIsExiting(true);
197
270
  // Cancel any in-flight agent run first so its tools / network calls
198
271
  // don't keep emitting text after Ink unmounts and corrupt the
199
272
  // restored shell prompt.
@@ -207,6 +280,12 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
207
280
  activeAbortRef.current = null;
208
281
  }
209
282
  void (async () => {
283
+ // Yield once so React can commit the `isExiting=true` render
284
+ // (which strips the composer/footer) before we hand control to
285
+ // Ink's teardown. Without this, on the no-flushMemory path the
286
+ // exit() below races the next React commit and Ink snapshots the
287
+ // pre-exit frame with the composer still visible.
288
+ await new Promise((resolve) => setImmediate(resolve));
210
289
  let flushError = null;
211
290
  if (flushMemory) {
212
291
  // Bound the flush so a stuck LLM/network call cannot trap the TUI.
@@ -240,7 +319,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
240
319
  process.stderr.write(`warning: failed to flush memory on exit: ${message}\n`);
241
320
  });
242
321
  }
243
- onExit?.();
322
+ onExit?.({ wallMs: Date.now() - sessionStartRef.current });
244
323
  })();
245
324
  }, [exit, flushMemory, onExit]);
246
325
  useEffect(() => {
@@ -351,6 +430,12 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
351
430
  }, [updateDisplayMessages]);
352
431
  const clearMessages = useCallback(() => {
353
432
  setMessages([]);
433
+ // Ink's <Static> writes items into terminal scrollback and never removes
434
+ // them — emptying the React state alone leaves the old output visible.
435
+ // Wipe screen + scrollback (xterm \x1b[3J) and bump the epoch below so
436
+ // Static remounts with a fresh internal cursor.
437
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
438
+ setClearEpoch((n) => n + 1);
354
439
  }, []);
355
440
  const openPicker = useCallback((mode, providerId) => {
356
441
  if (mode === "key") {
@@ -454,6 +539,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
454
539
  runMemoryCompaction,
455
540
  runMemorySummary,
456
541
  runMemoryRefresh,
542
+ getThemeMode: () => themeMode,
543
+ getResolvedTheme: () => themeResolved,
544
+ setThemeMode: applyThemeMode,
457
545
  });
458
546
  if (handled && result) {
459
547
  addMessage("assistant", result);
@@ -483,6 +571,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
483
571
  runMemoryCompaction,
484
572
  runMemorySummary,
485
573
  runMemoryRefresh,
574
+ getThemeMode: () => themeMode,
575
+ getResolvedTheme: () => themeResolved,
576
+ setThemeMode: applyThemeMode,
486
577
  });
487
578
  if (handled && result) {
488
579
  addMessage("assistant", result);
@@ -672,6 +763,21 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
672
763
  }
673
764
  break;
674
765
  }
766
+ case "tool_update": {
767
+ const tc = toolCalls.find((t) => t.id === event.id);
768
+ if (tc) {
769
+ tc.metadata = mergeToolMetadata(tc.metadata, event.update.metadata);
770
+ if (event.update.message) {
771
+ tc.result = event.update.message;
772
+ }
773
+ tc.isError = event.update.status === "failed"
774
+ || event.update.status === "blocked"
775
+ || event.update.status === "cancelled";
776
+ setStreamingTools([...toolCalls]);
777
+ syncStreamingParts();
778
+ }
779
+ break;
780
+ }
675
781
  case "todos_updated": {
676
782
  setTodos(event.todos);
677
783
  break;
@@ -764,6 +870,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
764
870
  runMemoryCompaction,
765
871
  runMemorySummary,
766
872
  runMemoryRefresh,
873
+ getThemeMode: () => themeMode,
874
+ getResolvedTheme: () => themeResolved,
875
+ setThemeMode: applyThemeMode,
767
876
  });
768
877
  if (handled) {
769
878
  if (agent.mode !== permissionMode) {
@@ -823,81 +932,84 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
823
932
  const mcpConnectedCount = mcpStates.filter((state) => state.status.kind === "connected").length;
824
933
  const hasAgentsFile = useMemo(() => existsSync(join(args.cwd, "AGENTS.md")) || existsSync(join(args.cwd, ".bubble", "AGENTS.md")), [args.cwd]);
825
934
  const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, modelLabel: agent.model ? displayModel(agent.model) : undefined, cwd: friendlyCwd(args.cwd), tips: buildTips(agent, safeRegistry), skillsCount: safeSkillRegistry.summaries().length, mcpConnectedCount: mcpConnectedCount, mcpTotalCount: mcpStates.length, hasAgentsFile: hasAgentsFile })) : null;
826
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, padding: 1, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }), pickerMode === "model" && (_jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: () => setPickerMode(null) })), pickerMode === "provider" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
827
- .filter((p) => isUserVisibleProvider(p.id))
828
- .map((p) => {
829
- const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
830
- const configuredLabel = configured?.apiKey ? "configured" : "needs key";
831
- return {
832
- id: p.id,
833
- name: `${p.name} [${configuredLabel}]`,
834
- enabled: true,
835
- };
836
- }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: () => setPickerMode(null) })), pickerMode === "provider-add" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
837
- .filter((p) => isUserVisibleProvider(p.id))
838
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: () => setPickerMode(null), title: "Add Provider" })), pickerMode === "login" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
839
- .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
840
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: () => setPickerMode(null), title: "Select Login Provider" })), pickerMode === "logout" && (_jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
841
- .filter((p) => safeRegistry.getAuthStorage().has(p.id))
842
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: () => setPickerMode(null), title: "Select Logout Provider" })), pickerMode === "key" && keyTarget && (_jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
843
- setPickerMode(null);
844
- setKeyProviderId(null);
845
- } })), pickerMode === "skill" && (_jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: async (name) => {
846
- setPickerMode(null);
847
- const { handled, result } = await slashRegistry.execute(`/skill ${name}`, {
848
- agent,
849
- addMessage,
850
- clearMessages,
851
- cwd: args.cwd,
852
- exit: () => { requestExit(); },
853
- sessionManager,
854
- createProvider: createProvider ?? (() => {
855
- throw new Error("Provider creation not available");
856
- }),
857
- openPicker,
858
- registry: safeRegistry,
859
- skillRegistry: safeSkillRegistry,
860
- bashAllowlist,
861
- settingsManager,
862
- lspService,
863
- mcpManager,
864
- flushMemory,
865
- runMemoryCompaction,
866
- runMemorySummary,
867
- runMemoryRefresh,
868
- });
869
- if (handled && result)
870
- addMessage("assistant", result);
871
- }, onCancel: () => setPickerMode(null) }))] }), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
872
- const resolve = pendingPlan.resolve;
873
- setPendingPlan(null);
874
- resolve({ action: "approve", plan: finalPlan });
875
- }, onReject: (reason) => {
876
- const resolve = pendingPlan.resolve;
877
- setPendingPlan(null);
878
- resolve({ action: "reject", reason });
879
- } }) })), pendingApproval && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
880
- const resolve = pendingApproval.resolve;
881
- setPendingApproval(null);
882
- resolve(decision);
883
- }, onAllowBashPrefix: (prefix) => {
884
- bashAllowlist?.add(prefix);
885
- } }) })), pendingQuestion && !pickerMode && !pendingPlan && !pendingApproval && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
886
- questionController?.reply(pendingQuestion.id, answers);
887
- setPendingQuestion(null);
888
- }, onCancel: () => {
889
- questionController?.reject(pendingQuestion.id);
890
- setPendingQuestion(null);
891
- } }) })), isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, runStartedAt: runStartRef.current ?? undefined, nowTick: nowTick }) })), !pickerMode && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, disabled: isRunning || !!pendingPlan || !!pendingApproval || !!pendingQuestion, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), _jsx(FooterBar, { data: buildFooterData({
892
- cwd: args.cwd,
893
- providerId: agent.providerId || safeRegistry.getDefault()?.id || "unknown",
894
- model: displayModel(agent.model) || "no model",
895
- thinkingLevel,
896
- showThinking: getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2,
897
- mode: permissionMode,
898
- usageTotals,
899
- verboseTrace,
900
- }) })] }));
935
+ return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }, clearEpoch), pickerMode === "model" && (_jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: () => setPickerMode(null) })), pickerMode === "provider" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
936
+ .filter((p) => isUserVisibleProvider(p.id))
937
+ .map((p) => {
938
+ const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
939
+ const configuredLabel = configured?.apiKey ? "configured" : "needs key";
940
+ return {
941
+ id: p.id,
942
+ name: `${p.name} [${configuredLabel}]`,
943
+ enabled: true,
944
+ };
945
+ }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: () => setPickerMode(null) })), pickerMode === "provider-add" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
946
+ .filter((p) => isUserVisibleProvider(p.id))
947
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: () => setPickerMode(null), title: "Add Provider" })), pickerMode === "login" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
948
+ .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
949
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: () => setPickerMode(null), title: "Select Login Provider" })), pickerMode === "logout" && (_jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
950
+ .filter((p) => safeRegistry.getAuthStorage().has(p.id))
951
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: () => setPickerMode(null), title: "Select Logout Provider" })), pickerMode === "key" && keyTarget && (_jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
952
+ setPickerMode(null);
953
+ setKeyProviderId(null);
954
+ } })), pickerMode === "skill" && (_jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: async (name) => {
955
+ setPickerMode(null);
956
+ const { handled, result } = await slashRegistry.execute(`/skill ${name}`, {
957
+ agent,
958
+ addMessage,
959
+ clearMessages,
960
+ cwd: args.cwd,
961
+ exit: () => { requestExit(); },
962
+ sessionManager,
963
+ createProvider: createProvider ?? (() => {
964
+ throw new Error("Provider creation not available");
965
+ }),
966
+ openPicker,
967
+ registry: safeRegistry,
968
+ skillRegistry: safeSkillRegistry,
969
+ bashAllowlist,
970
+ settingsManager,
971
+ lspService,
972
+ mcpManager,
973
+ flushMemory,
974
+ runMemoryCompaction,
975
+ runMemorySummary,
976
+ runMemoryRefresh,
977
+ getThemeMode: () => themeMode,
978
+ getResolvedTheme: () => themeResolved,
979
+ setThemeMode: applyThemeMode,
980
+ });
981
+ if (handled && result)
982
+ addMessage("assistant", result);
983
+ }, onCancel: () => setPickerMode(null) }))] }), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
984
+ const resolve = pendingPlan.resolve;
985
+ setPendingPlan(null);
986
+ resolve({ action: "approve", plan: finalPlan });
987
+ }, onReject: (reason) => {
988
+ const resolve = pendingPlan.resolve;
989
+ setPendingPlan(null);
990
+ resolve({ action: "reject", reason });
991
+ } }) })), pendingApproval && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
992
+ const resolve = pendingApproval.resolve;
993
+ setPendingApproval(null);
994
+ resolve(decision);
995
+ }, onAllowBashPrefix: (prefix) => {
996
+ bashAllowlist?.add(prefix);
997
+ } }) })), pendingQuestion && !pickerMode && !pendingPlan && !pendingApproval && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
998
+ questionController?.reply(pendingQuestion.id, answers);
999
+ setPendingQuestion(null);
1000
+ }, onCancel: () => {
1001
+ questionController?.reject(pendingQuestion.id);
1002
+ setPendingQuestion(null);
1003
+ } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, runStartedAt: runStartRef.current ?? undefined, nowTick: nowTick }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, disabled: isRunning || !!pendingPlan || !!pendingApproval || !!pendingQuestion, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(FooterBar, { data: buildFooterData({
1004
+ cwd: args.cwd,
1005
+ providerId: agent.providerId || safeRegistry.getDefault()?.id || "unknown",
1006
+ model: displayModel(agent.model) || "no model",
1007
+ thinkingLevel,
1008
+ showThinking: getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2,
1009
+ mode: permissionMode,
1010
+ usageTotals,
1011
+ verboseTrace,
1012
+ }) }))] }) }));
901
1013
  }
902
1014
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
903
1015
  const GENERIC_PHRASES = [
@@ -963,6 +1075,7 @@ function formatTokensApprox(chars) {
963
1075
  return `${Math.round(tokens / 1000)}k`;
964
1076
  }
965
1077
  function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, runStartedAt, nowTick, }) {
1078
+ const theme = useTheme();
966
1079
  const [frameIndex, setFrameIndex] = useState(0);
967
1080
  const [idlePhrase, setIdlePhrase] = useState(() => GENERIC_PHRASES[0]);
968
1081
  // Frame timer is independent of the agent state — keeps animation smooth.
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { theme } from "../theme.js";
3
+ import { useTheme } from "../theme.js";
4
4
  import { ApprovalSelect } from "./select.js";
5
5
  import { DiffView } from "./diff-view.js";
6
6
  import { inferBashPrefix } from "../../approval/session-cache.js";
7
7
  import { classifyBashDanger } from "../../approval/danger.js";
8
8
  export function ApprovalDialog({ request, onDecision, onAllowBashPrefix, }) {
9
+ const theme = useTheme();
9
10
  const options = buildOptions(request);
10
11
  const onSubmit = (id, extras) => {
11
12
  switch (id) {
@@ -103,11 +104,13 @@ function RequestPreview({ request }) {
103
104
  }
104
105
  }
105
106
  function BashPreview({ command, cwd }) {
107
+ const theme = useTheme();
106
108
  const danger = classifyBashDanger(command);
107
109
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "$ " }), _jsx(Text, { children: command })] }), _jsxs(Text, { color: theme.muted, children: ["cwd: ", compressHome(cwd)] }), danger && (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.warning, bold: true, children: ["\u26A0 ", danger.pattern, ":"] }), _jsxs(Text, { color: theme.warning, children: [" ", danger.message] })] }))] }));
108
110
  }
109
111
  const MAX_WRITE_PREVIEW_LINES = 20;
110
112
  function WritePreview({ path, content }) {
113
+ const theme = useTheme();
111
114
  const lines = content.split("\n");
112
115
  const shown = lines.slice(0, MAX_WRITE_PREVIEW_LINES);
113
116
  const overflow = lines.length - shown.length;
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { theme } from "../theme.js";
3
+ import { useTheme } from "../theme.js";
4
4
  import { parseDiffHunks } from "../../approval/diff-hunks.js";
5
5
  const DEFAULT_MAX_LINES = 40;
6
6
  export function DiffView({ diff, maxLines = DEFAULT_MAX_LINES }) {
7
+ const theme = useTheme();
7
8
  const hunks = parseDiffHunks(diff);
8
9
  if (hunks.length === 0) {
9
10
  return (_jsx(Text, { color: theme.muted, children: "(no diff body to display)" }));
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
- import { theme } from "../theme.js";
4
+ import { useTheme } from "../theme.js";
5
5
  export function ApprovalSelect({ options, onSubmit, onCancel, hint, initialIndex = 0, }) {
6
+ const theme = useTheme();
6
7
  const [focusIndex, setFocusIndex] = useState(Math.max(0, Math.min(initialIndex, options.length - 1)));
7
8
  const [amending, setAmending] = useState(false);
8
9
  const [amendText, setAmendText] = useState("");