@bubblebrain-ai/bubble 0.0.7 → 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 (119) 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 +18 -0
  14. package/dist/agent.js +188 -16
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -2
  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.d.ts +34 -0
  29. package/dist/context/usage.js +213 -0
  30. package/dist/diff-stats.d.ts +5 -0
  31. package/dist/diff-stats.js +21 -0
  32. package/dist/main.js +68 -7
  33. package/dist/mcp/transports.d.ts +1 -0
  34. package/dist/mcp/transports.js +8 -0
  35. package/dist/model-catalog.d.ts +9 -0
  36. package/dist/model-catalog.js +17 -1
  37. package/dist/orchestrator/default-hooks.js +24 -18
  38. package/dist/prompt/compose.js +2 -1
  39. package/dist/prompt/provider-prompts/kimi.js +3 -1
  40. package/dist/provider-openai-codex.d.ts +13 -2
  41. package/dist/provider-openai-codex.js +81 -32
  42. package/dist/provider-registry.js +22 -6
  43. package/dist/provider-transform.d.ts +3 -1
  44. package/dist/provider-transform.js +15 -0
  45. package/dist/provider.d.ts +4 -1
  46. package/dist/provider.js +89 -4
  47. package/dist/reasoning-debug.d.ts +7 -0
  48. package/dist/reasoning-debug.js +30 -0
  49. package/dist/session-log.js +13 -2
  50. package/dist/session-types.d.ts +1 -1
  51. package/dist/slash-commands/commands.js +60 -2
  52. package/dist/slash-commands/types.d.ts +7 -0
  53. package/dist/tools/agent-lifecycle.js +22 -4
  54. package/dist/tools/edit.js +7 -2
  55. package/dist/tools/file-state.d.ts +19 -0
  56. package/dist/tools/file-state.js +15 -0
  57. package/dist/tools/glob.js +2 -1
  58. package/dist/tools/grep.js +2 -2
  59. package/dist/tools/lsp.js +2 -2
  60. package/dist/tools/path-utils.d.ts +2 -0
  61. package/dist/tools/path-utils.js +16 -0
  62. package/dist/tools/read.d.ts +1 -1
  63. package/dist/tools/read.js +207 -14
  64. package/dist/tools/write.js +3 -2
  65. package/dist/tui/escape-confirmation.d.ts +15 -0
  66. package/dist/tui/escape-confirmation.js +30 -0
  67. package/dist/tui/run.js +93 -23
  68. package/dist/tui-ink/app.d.ts +52 -0
  69. package/dist/tui-ink/app.js +1129 -0
  70. package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
  71. package/dist/tui-ink/approval/approval-dialog.js +132 -0
  72. package/dist/tui-ink/approval/diff-view.d.ts +7 -0
  73. package/dist/tui-ink/approval/diff-view.js +44 -0
  74. package/dist/tui-ink/approval/select.d.ts +35 -0
  75. package/dist/tui-ink/approval/select.js +88 -0
  76. package/dist/tui-ink/code-highlight.d.ts +8 -0
  77. package/dist/tui-ink/code-highlight.js +122 -0
  78. package/dist/tui-ink/detect-theme.d.ts +19 -0
  79. package/dist/tui-ink/detect-theme.js +123 -0
  80. package/dist/tui-ink/display-history.d.ts +38 -0
  81. package/dist/tui-ink/display-history.js +130 -0
  82. package/dist/tui-ink/edit-diff.d.ts +11 -0
  83. package/dist/tui-ink/edit-diff.js +52 -0
  84. package/dist/tui-ink/file-mentions.d.ts +29 -0
  85. package/dist/tui-ink/file-mentions.js +174 -0
  86. package/dist/tui-ink/footer.d.ts +19 -0
  87. package/dist/tui-ink/footer.js +45 -0
  88. package/dist/tui-ink/image-paste.d.ts +54 -0
  89. package/dist/tui-ink/image-paste.js +288 -0
  90. package/dist/tui-ink/input-box.d.ts +41 -0
  91. package/dist/tui-ink/input-box.js +694 -0
  92. package/dist/tui-ink/input-history.d.ts +16 -0
  93. package/dist/tui-ink/input-history.js +81 -0
  94. package/dist/tui-ink/markdown.d.ts +38 -0
  95. package/dist/tui-ink/markdown.js +394 -0
  96. package/dist/tui-ink/message-list.d.ts +33 -0
  97. package/dist/tui-ink/message-list.js +667 -0
  98. package/dist/tui-ink/model-picker.d.ts +43 -0
  99. package/dist/tui-ink/model-picker.js +331 -0
  100. package/dist/tui-ink/plan-confirm.d.ts +7 -0
  101. package/dist/tui-ink/plan-confirm.js +105 -0
  102. package/dist/tui-ink/question-dialog.d.ts +8 -0
  103. package/dist/tui-ink/question-dialog.js +99 -0
  104. package/dist/tui-ink/recent-activity.d.ts +8 -0
  105. package/dist/tui-ink/recent-activity.js +71 -0
  106. package/dist/tui-ink/run.d.ts +37 -0
  107. package/dist/tui-ink/run.js +53 -0
  108. package/dist/tui-ink/theme.d.ts +66 -0
  109. package/dist/tui-ink/theme.js +115 -0
  110. package/dist/tui-ink/todos.d.ts +7 -0
  111. package/dist/tui-ink/todos.js +46 -0
  112. package/dist/tui-ink/trace-groups.d.ts +27 -0
  113. package/dist/tui-ink/trace-groups.js +389 -0
  114. package/dist/tui-ink/use-terminal-size.d.ts +4 -0
  115. package/dist/tui-ink/use-terminal-size.js +21 -0
  116. package/dist/tui-ink/welcome.d.ts +18 -0
  117. package/dist/tui-ink/welcome.js +138 -0
  118. package/dist/types.d.ts +10 -0
  119. package/package.json +7 -1
@@ -0,0 +1,1129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import { AgentAbortError } from "../agent.js";
5
+ import { registry as slashRegistry } from "../slash-commands/index.js";
6
+ import { UserConfig, maskKey } from "../config.js";
7
+ import { InputBox } from "./input-box.js";
8
+ import { MessageList } from "./message-list.js";
9
+ import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, nextDisplayMessageKey, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
10
+ import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
11
+ import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
12
+ import { BUILTIN_PROVIDERS, ProviderRegistry, displayModel, isUserVisibleProvider } from "../provider-registry.js";
13
+ import { buildSystemPrompt } from "../system-prompt.js";
14
+ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
15
+ import { FooterBar, buildFooterData } from "./footer.js";
16
+ import { SkillRegistry } from "../skills/registry.js";
17
+ import { parseSkillInvocation } from "../skills/invocation.js";
18
+ import { useTerminalSize } from "./use-terminal-size.js";
19
+ import { WelcomeBanner, shouldShowWelcomeBanner } from "./welcome.js";
20
+ import { expandAtMentions } from "./file-mentions.js";
21
+ import { TodosPanel } from "./todos.js";
22
+ import { PlanConfirm } from "./plan-confirm.js";
23
+ import { ApprovalDialog } from "./approval/approval-dialog.js";
24
+ import { getNextPermissionMode } from "../permission/mode.js";
25
+ import { QuestionDialog } from "./question-dialog.js";
26
+ import os from "node:os";
27
+ import { existsSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ function buildTips(agent, registry) {
30
+ const tips = [];
31
+ const hasProvider = registry.getEnabled().length > 0;
32
+ if (!hasProvider) {
33
+ tips.push("Run /login or /provider --add to configure a model");
34
+ }
35
+ else if (agent.model) {
36
+ tips.push(`Ready with ${displayModel(agent.model)}`);
37
+ }
38
+ else {
39
+ tips.push("Run /model to pick a model");
40
+ }
41
+ tips.push("Type @ to reference a file");
42
+ tips.push("Type / for commands and skills");
43
+ return tips;
44
+ }
45
+ function friendlyCwd(cwd) {
46
+ const home = os.homedir();
47
+ if (cwd === home)
48
+ return "~";
49
+ if (cwd.startsWith(home + "/"))
50
+ return "~" + cwd.slice(home.length);
51
+ return cwd;
52
+ }
53
+ function reconstructDisplayMessages(agentMessages) {
54
+ const result = [];
55
+ for (const m of agentMessages) {
56
+ if (m.role === "system" || m.role === "tool")
57
+ continue;
58
+ if (m.role === "user") {
59
+ if (m.isMeta)
60
+ continue; // <system-reminder> injections are not user-visible
61
+ result.push({
62
+ key: nextDisplayMessageKey("user"),
63
+ role: "user",
64
+ content: typeof m.content === "string" ? m.content : "(multimedia)",
65
+ });
66
+ }
67
+ else if (m.role === "assistant") {
68
+ const toolCalls = [];
69
+ if (m.toolCalls) {
70
+ for (const tc of m.toolCalls) {
71
+ let args = {};
72
+ try {
73
+ args = JSON.parse(tc.arguments || "{}");
74
+ }
75
+ catch {
76
+ args = {};
77
+ }
78
+ const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
79
+ toolCalls.push({
80
+ id: tc.id,
81
+ name: tc.name,
82
+ args,
83
+ result: toolResult ? toolResult.content : undefined,
84
+ isError: toolResult ? toolResult.content?.startsWith?.("Error:") : false,
85
+ metadata: toolResult ? toolResult.metadata : undefined,
86
+ });
87
+ }
88
+ }
89
+ result.push({
90
+ key: nextDisplayMessageKey("asst"),
91
+ role: "assistant",
92
+ content: m.content,
93
+ reasoning: m.reasoning || undefined,
94
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
95
+ });
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+ /**
101
+ * Streaming tool arguments arrive as an incomplete JSON buffer. We can't
102
+ * JSON.parse() until the closing brace lands, but the user wants to see the
103
+ * short identifying fields (path, command, …) as soon as the model emits
104
+ * them so the tool row header reflects what's happening.
105
+ *
106
+ * Intentionally limited to short, single-line fields. Long fields like
107
+ * `content` are *not* surfaced live: rendering thousands of partial lines
108
+ * per delta floods the terminal and the partial value can break around
109
+ * unescaped sequences. The final value lands when the tool actually
110
+ * executes and tool_start delivers canonical args.
111
+ */
112
+ function parsePartialArgs(buffer, previous) {
113
+ // If the buffer is now valid JSON, prefer the real parse.
114
+ try {
115
+ const parsed = JSON.parse(buffer);
116
+ if (parsed && typeof parsed === "object")
117
+ return parsed;
118
+ }
119
+ catch {
120
+ // fall through to partial extraction below
121
+ }
122
+ const result = { ...previous };
123
+ const FIELDS = ["path", "command", "pattern", "url", "query"];
124
+ for (const field of FIELDS) {
125
+ // Match a complete-looking quoted string. Requires a closing quote so we
126
+ // don't surface half-typed paths that may still change as bytes arrive.
127
+ const match = buffer.match(new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`));
128
+ if (match) {
129
+ const raw = match[1] ?? "";
130
+ result[field] = raw
131
+ .replace(/\\n/g, "\n")
132
+ .replace(/\\t/g, "\t")
133
+ .replace(/\\"/g, '"')
134
+ .replace(/\\\\/g, "\\");
135
+ }
136
+ }
137
+ return result;
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
+ }
166
+ /**
167
+ * Coerce a freshly-constructed DisplayMessage into one that carries a stable
168
+ * `key`. Centralizes the safety net so callers don't have to remember to call
169
+ * nextDisplayMessageKey on every push.
170
+ */
171
+ function withMessageKey(message) {
172
+ if (message.key)
173
+ return message;
174
+ const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
175
+ return { ...message, key: nextDisplayMessageKey(prefix) };
176
+ }
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;
193
+ const { exit } = useApp();
194
+ const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
195
+ const [clearEpoch, setClearEpoch] = useState(0);
196
+ const [isRunning, setIsRunning] = useState(false);
197
+ const [streamingContent, setStreamingContent] = useState("");
198
+ const [streamingReasoning, setStreamingReasoning] = useState("");
199
+ const [streamingTools, setStreamingTools] = useState([]);
200
+ const [streamingParts, setStreamingParts] = useState([]);
201
+ const [usageTotals, setUsageTotals] = useState({ prompt: 0, completion: 0 });
202
+ const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
203
+ const [permissionMode, setPermissionMode] = useState(agent.mode);
204
+ const [todos, setTodos] = useState(() => agent.getTodos());
205
+ const [pendingPlan, setPendingPlan] = useState(null);
206
+ const [pendingApproval, setPendingApproval] = useState(null);
207
+ const [pendingQuestion, setPendingQuestion] = useState(null);
208
+ const [pickerMode, setPickerMode] = useState(null);
209
+ const [keyProviderId, setKeyProviderId] = useState(null);
210
+ const [verboseTrace, setVerboseTrace] = useState(false);
211
+ const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
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]);
233
+ const activeAbortRef = useRef(null);
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);
241
+ // 1Hz tick used to refresh elapsed counters on in-progress tool rows and
242
+ // on the WaitingIndicator. Only ticks while the agent is running so we
243
+ // don't churn renders at idle.
244
+ const [nowTick, setNowTick] = useState(() => Date.now());
245
+ // Timestamp of when the current agent run started — drives elapsed display
246
+ // on the WaitingIndicator.
247
+ const runStartRef = useRef(null);
248
+ // Mark the moment the run started; flips back to null in the finally block.
249
+ useEffect(() => {
250
+ if (!isRunning)
251
+ return;
252
+ setNowTick(Date.now());
253
+ const t = setInterval(() => setNowTick(Date.now()), 1000);
254
+ return () => clearInterval(t);
255
+ }, [isRunning]);
256
+ const userConfig = new UserConfig();
257
+ const safeRegistry = registry ?? new ProviderRegistry(userConfig);
258
+ const safeSkillRegistry = skillRegistry ?? new SkillRegistry({
259
+ cwd: args.cwd,
260
+ skillPaths: userConfig.getSkillPaths(),
261
+ });
262
+ const requestExit = useCallback(() => {
263
+ if (exitRequestedRef.current)
264
+ return;
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);
270
+ // Cancel any in-flight agent run first so its tools / network calls
271
+ // don't keep emitting text after Ink unmounts and corrupt the
272
+ // restored shell prompt.
273
+ if (activeAbortRef.current) {
274
+ try {
275
+ activeAbortRef.current.abort(new AgentAbortError("Exiting Bubble."));
276
+ }
277
+ catch {
278
+ // ignore — abort is best effort during shutdown
279
+ }
280
+ activeAbortRef.current = null;
281
+ }
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));
289
+ let flushError = null;
290
+ if (flushMemory) {
291
+ // Bound the flush so a stuck LLM/network call cannot trap the TUI.
292
+ let timer;
293
+ try {
294
+ await Promise.race([
295
+ flushMemory(),
296
+ new Promise((_, reject) => {
297
+ timer = setTimeout(() => reject(new Error("flushMemory timed out after 3s")), 3000);
298
+ }),
299
+ ]);
300
+ }
301
+ catch (err) {
302
+ flushError = err;
303
+ }
304
+ finally {
305
+ if (timer)
306
+ clearTimeout(timer);
307
+ }
308
+ }
309
+ // Hand off to Ink. Ink's render instance owns TTY teardown (raw mode,
310
+ // cursor, alt-screen); doing it ourselves here races with that and
311
+ // leaves the terminal in odd states. run.tsx awaits waitUntilExit()
312
+ // and then main.ts handles the rest.
313
+ exit();
314
+ // Surface flush failures *after* Ink has restored the screen so the
315
+ // warning lands on the real shell instead of being clobbered.
316
+ if (flushError) {
317
+ const message = flushError instanceof Error ? flushError.message : String(flushError);
318
+ process.nextTick(() => {
319
+ process.stderr.write(`warning: failed to flush memory on exit: ${message}\n`);
320
+ });
321
+ }
322
+ onExit?.({ wallMs: Date.now() - sessionStartRef.current });
323
+ })();
324
+ }, [exit, flushMemory, onExit]);
325
+ useEffect(() => {
326
+ if (!planHandlerRef)
327
+ return;
328
+ planHandlerRef.current = (plan) => new Promise((resolve) => {
329
+ setPendingPlan({ plan, resolve });
330
+ });
331
+ return () => {
332
+ if (planHandlerRef.current) {
333
+ planHandlerRef.current = undefined;
334
+ }
335
+ };
336
+ }, [planHandlerRef]);
337
+ useEffect(() => {
338
+ if (!approvalHandlerRef)
339
+ return;
340
+ approvalHandlerRef.current = (request) => new Promise((resolve) => {
341
+ setPendingApproval({ request, resolve });
342
+ });
343
+ return () => {
344
+ if (approvalHandlerRef.current) {
345
+ approvalHandlerRef.current = undefined;
346
+ }
347
+ };
348
+ }, [approvalHandlerRef]);
349
+ useEffect(() => {
350
+ if (!questionController)
351
+ return;
352
+ const syncFirstPending = () => {
353
+ setPendingQuestion((current) => current ?? questionController.list()[0] ?? null);
354
+ };
355
+ const unsubscribe = questionController.subscribe((event) => {
356
+ if (event.type === "asked") {
357
+ setPendingQuestion(event.request);
358
+ return;
359
+ }
360
+ setPendingQuestion((current) => current?.id === event.request.id ? null : current);
361
+ setTimeout(syncFirstPending, 0);
362
+ });
363
+ syncFirstPending();
364
+ return unsubscribe;
365
+ }, [questionController]);
366
+ const rebuildSystemPrompt = useCallback((overrides) => {
367
+ const modelParts = agent.model.includes(":")
368
+ ? agent.model.split(":")
369
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
370
+ const providerId = modelParts[0];
371
+ agent.setSystemPrompt(buildSystemPrompt({
372
+ agentName: "Bubble",
373
+ configuredProvider: providerId,
374
+ configuredModel: displayModel(agent.model),
375
+ configuredModelId: agent.model,
376
+ thinkingLevel: overrides?.thinkingLevel ?? agent.thinking,
377
+ mode: overrides?.mode ?? agent.mode,
378
+ workingDir: args.cwd,
379
+ skills: safeSkillRegistry?.summaries() ?? [],
380
+ }));
381
+ }, [agent, args.cwd, safeRegistry, safeSkillRegistry]);
382
+ useInput((input, key) => {
383
+ if (pendingPlan || pendingApproval || pendingQuestion)
384
+ return;
385
+ if (key.ctrl && input === "o" && !pickerMode) {
386
+ setVerboseTrace((v) => !v);
387
+ return;
388
+ }
389
+ // Ctrl+R: cycle thinking level (formerly Shift+Tab)
390
+ if (key.ctrl && input === "r" && !pickerMode) {
391
+ const modelParts = agent.model.includes(":")
392
+ ? agent.model.split(":")
393
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
394
+ const providerId = modelParts[0];
395
+ const modelId = modelParts.slice(1).join(":");
396
+ const availableLevels = getAvailableThinkingLevels(providerId, modelId);
397
+ const currentLevel = normalizeThinkingLevel(agent.thinking, availableLevels);
398
+ const currentIndex = availableLevels.indexOf(currentLevel);
399
+ const nextLevel = availableLevels[(currentIndex + 1) % availableLevels.length];
400
+ agent.thinking = nextLevel;
401
+ rebuildSystemPrompt({ thinkingLevel: nextLevel });
402
+ userConfig.setDefaultThinkingLevel(nextLevel);
403
+ setThinkingLevel(nextLevel);
404
+ sessionManager?.setMetadata({ model: agent.model, thinkingLevel: nextLevel, reasoningEffort: nextLevel });
405
+ sessionManager?.appendMarker("thinking_level_switch", nextLevel);
406
+ return;
407
+ }
408
+ // Shift+Tab: cycle through permission modes (default → acceptEdits → plan
409
+ // → [bypassPermissions if enabled] → default). Agent.setMode injects a
410
+ // <system-reminder>, so we do not rebuild the cache-friendly system prompt here.
411
+ if (key.tab && key.shift && !pickerMode) {
412
+ const nextMode = getNextPermissionMode(agent.mode);
413
+ agent.setMode(nextMode);
414
+ setPermissionMode(nextMode);
415
+ sessionManager?.appendMarker("mode_switch", nextMode);
416
+ return;
417
+ }
418
+ if (key.escape && !pickerMode) {
419
+ if (isRunning && activeAbortRef.current) {
420
+ activeAbortRef.current.abort(new AgentAbortError("Agent run cancelled by user."));
421
+ return;
422
+ }
423
+ }
424
+ });
425
+ const updateDisplayMessages = useCallback((updater) => {
426
+ setMessages((prev) => compactDisplayMessages(updater(prev).map(withMessageKey)));
427
+ }, []);
428
+ const addMessage = useCallback((role, content) => {
429
+ updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
430
+ }, [updateDisplayMessages]);
431
+ const clearMessages = useCallback(() => {
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);
439
+ }, []);
440
+ const openPicker = useCallback((mode, providerId) => {
441
+ if (mode === "key") {
442
+ setKeyProviderId(providerId ?? null);
443
+ }
444
+ setPickerMode(mode);
445
+ }, []);
446
+ const handleModelSelect = useCallback((model) => {
447
+ const run = async () => {
448
+ agent.model = model;
449
+ const decoded = model.includes(":")
450
+ ? model.split(":")
451
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", model];
452
+ const providerId = decoded[0];
453
+ await safeRegistry.prepareProvider(providerId);
454
+ const provider = safeRegistry.getConfigured().find((item) => item.id === providerId);
455
+ if (!provider?.apiKey || !createProvider) {
456
+ addMessage("error", `Provider ${providerId} is not configured or has no active credentials.`);
457
+ setPickerMode(null);
458
+ return;
459
+ }
460
+ const modelId = model.includes(":") ? model.split(":").slice(1).join(":") : model;
461
+ agent.thinking = normalizeThinkingLevel(agent.thinking || getDefaultThinkingLevel(providerId, modelId), getAvailableThinkingLevels(providerId, modelId));
462
+ agent.setProvider(createProvider(providerId, provider.apiKey, provider.baseURL));
463
+ agent.providerId = providerId;
464
+ agent.setSystemPrompt(buildSystemPrompt({
465
+ agentName: "Bubble",
466
+ configuredProvider: providerId,
467
+ configuredModel: displayModel(model),
468
+ configuredModelId: model,
469
+ thinkingLevel: agent.thinking,
470
+ workingDir: args.cwd,
471
+ skills: safeSkillRegistry?.summaries() ?? [],
472
+ }));
473
+ userConfig.pushRecentModel(model);
474
+ setThinkingLevel(agent.thinking);
475
+ sessionManager?.setMetadata({ model, thinkingLevel: agent.thinking, reasoningEffort: agent.thinking });
476
+ sessionManager?.appendMarker("model_switch", model);
477
+ addMessage("assistant", `Model switched to ${displayModel(model)}.`);
478
+ setPickerMode(null);
479
+ };
480
+ void run();
481
+ }, [agent, addMessage, sessionManager, userConfig, safeRegistry, createProvider]);
482
+ const handleProviderSelect = useCallback(async (providerId) => {
483
+ await safeRegistry.prepareProvider(providerId);
484
+ const configured = safeRegistry.getConfigured();
485
+ const p = configured.find((x) => x.id === providerId);
486
+ const builtin = BUILTIN_PROVIDERS.find((x) => x.id === providerId);
487
+ if (!p && !builtin) {
488
+ addMessage("error", `Provider ${providerId} not found.`);
489
+ setPickerMode(null);
490
+ return;
491
+ }
492
+ if (!p?.apiKey) {
493
+ if (!p && builtin) {
494
+ safeRegistry.addProvider(providerId, "");
495
+ }
496
+ safeRegistry.setDefault(providerId);
497
+ setKeyProviderId(providerId);
498
+ setPickerMode("key");
499
+ return;
500
+ }
501
+ safeRegistry.setDefault(providerId);
502
+ agent.setProvider(createProvider(providerId, p.apiKey, p.baseURL));
503
+ agent.providerId = providerId;
504
+ addMessage("assistant", `Switched to provider ${p.name}. Use /model to pick a model.`);
505
+ setPickerMode(null);
506
+ }, [addMessage, agent, createProvider, safeRegistry]);
507
+ const handleProviderAddSelect = useCallback((providerId) => {
508
+ const ok = safeRegistry.addProvider(providerId, "");
509
+ if (!ok) {
510
+ addMessage("error", `Provider ${providerId} could not be added.`);
511
+ setPickerMode(null);
512
+ return;
513
+ }
514
+ safeRegistry.setDefault(providerId);
515
+ setKeyProviderId(providerId);
516
+ setPickerMode("key");
517
+ }, [addMessage, safeRegistry]);
518
+ const handleLoginProviderSelect = useCallback(async (providerId) => {
519
+ setPickerMode(null);
520
+ const command = `/login ${providerId}`;
521
+ const { handled, result } = await slashRegistry.execute(command, {
522
+ agent,
523
+ addMessage,
524
+ clearMessages,
525
+ cwd: args.cwd,
526
+ exit: () => { requestExit(); },
527
+ sessionManager,
528
+ createProvider: createProvider ?? (() => {
529
+ throw new Error("Provider creation not available");
530
+ }),
531
+ openPicker,
532
+ registry: safeRegistry,
533
+ skillRegistry: safeSkillRegistry,
534
+ bashAllowlist,
535
+ settingsManager,
536
+ lspService,
537
+ mcpManager,
538
+ flushMemory,
539
+ runMemoryCompaction,
540
+ runMemorySummary,
541
+ runMemoryRefresh,
542
+ getThemeMode: () => themeMode,
543
+ getResolvedTheme: () => themeResolved,
544
+ setThemeMode: applyThemeMode,
545
+ });
546
+ if (handled && result) {
547
+ addMessage("assistant", result);
548
+ }
549
+ }, [agent, addMessage, clearMessages, createProvider, exit, openPicker, safeRegistry, sessionManager]);
550
+ const handleLogoutProviderSelect = useCallback(async (providerId) => {
551
+ setPickerMode(null);
552
+ const command = `/logout ${providerId}`;
553
+ const { handled, result } = await slashRegistry.execute(command, {
554
+ agent,
555
+ addMessage,
556
+ clearMessages,
557
+ cwd: args.cwd,
558
+ exit: () => { requestExit(); },
559
+ sessionManager,
560
+ createProvider: createProvider ?? (() => {
561
+ throw new Error("Provider creation not available");
562
+ }),
563
+ openPicker,
564
+ registry: safeRegistry,
565
+ skillRegistry: safeSkillRegistry,
566
+ bashAllowlist,
567
+ settingsManager,
568
+ lspService,
569
+ mcpManager,
570
+ flushMemory,
571
+ runMemoryCompaction,
572
+ runMemorySummary,
573
+ runMemoryRefresh,
574
+ getThemeMode: () => themeMode,
575
+ getResolvedTheme: () => themeResolved,
576
+ setThemeMode: applyThemeMode,
577
+ });
578
+ if (handled && result) {
579
+ addMessage("assistant", result);
580
+ }
581
+ }, [agent, addMessage, clearMessages, createProvider, exit, openPicker, safeRegistry, sessionManager]);
582
+ const handleKeySubmit = useCallback((key) => {
583
+ const targetId = keyProviderId || safeRegistry.getDefault()?.id;
584
+ if (!targetId) {
585
+ addMessage("error", "No provider selected.");
586
+ setPickerMode(null);
587
+ setKeyProviderId(null);
588
+ return;
589
+ }
590
+ safeRegistry.updateProviderKey(targetId, key);
591
+ const p = safeRegistry.getConfigured().find((x) => x.id === targetId);
592
+ if (p && createProvider) {
593
+ agent.setProvider(createProvider(targetId, key, p.baseURL));
594
+ agent.providerId = targetId;
595
+ }
596
+ addMessage("assistant", `API key updated for ${p?.name || targetId} to ${maskKey(key)}.`);
597
+ setPickerMode(null);
598
+ setKeyProviderId(null);
599
+ }, [addMessage, agent, createProvider, keyProviderId, safeRegistry]);
600
+ const handleSubmit = useCallback(async (payload) => {
601
+ const normalized = typeof payload === "string" ? { text: payload, images: [] } : payload;
602
+ const input = normalized.text;
603
+ const images = normalized.images;
604
+ if (!input.trim() && images.length === 0)
605
+ return;
606
+ const runAgentInput = async (actualInput, displayInput, attachedImages = []) => {
607
+ const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
608
+ const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
609
+ if (!hasActiveProvider) {
610
+ addMessage("error", "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.");
611
+ return;
612
+ }
613
+ if (!agent.model) {
614
+ addMessage("error", "No model selected. Use /model after /login or provider setup.");
615
+ return;
616
+ }
617
+ const displayContent = attachedImages.length > 0
618
+ ? `${displayInput}${displayInput ? "\n" : ""}${attachedImages
619
+ .map((img, i) => `[image${attachedImages.length > 1 ? ` ${i + 1}` : ""}: ${img.filename ?? "clipboard"} · ${Math.max(1, Math.round(img.bytes / 1024))}KB]`)
620
+ .join(" ")}`
621
+ : displayInput;
622
+ updateDisplayMessages((prev) => [
623
+ ...prev,
624
+ withMessageKey({ role: "user", content: displayContent }),
625
+ ]);
626
+ setIsRunning(true);
627
+ runStartRef.current = Date.now();
628
+ setStreamingContent("");
629
+ setStreamingReasoning("");
630
+ setStreamingTools([]);
631
+ setStreamingParts([]);
632
+ let assistantContent = "";
633
+ let assistantReasoning = "";
634
+ const toolCalls = [];
635
+ const assistantParts = [];
636
+ const abortController = new AbortController();
637
+ activeAbortRef.current = abortController;
638
+ const syncStreamingParts = () => {
639
+ setStreamingParts(snapshotDisplayParts(assistantParts));
640
+ };
641
+ const hasAssistantOutput = () => (!!assistantContent ||
642
+ !!assistantReasoning ||
643
+ toolCalls.length > 0 ||
644
+ assistantParts.length > 0);
645
+ const commitAssistantMessage = () => {
646
+ if (!hasAssistantOutput())
647
+ return;
648
+ const currentParts = snapshotDisplayParts(assistantParts);
649
+ const currentToolCalls = [...toolCalls];
650
+ const partContent = assistantContent || contentFromParts(currentParts);
651
+ const partToolCalls = currentToolCalls.length > 0
652
+ ? currentToolCalls
653
+ : toolCallsFromParts(currentParts);
654
+ const msg = {
655
+ key: nextDisplayMessageKey("asst"),
656
+ role: "assistant",
657
+ content: partContent,
658
+ };
659
+ if (assistantReasoning) {
660
+ msg.reasoning = assistantReasoning;
661
+ }
662
+ if (partToolCalls.length > 0) {
663
+ msg.toolCalls = partToolCalls;
664
+ }
665
+ if (currentParts.length > 0) {
666
+ msg.parts = currentParts;
667
+ }
668
+ updateDisplayMessages((prev) => [...prev, msg]);
669
+ };
670
+ const clearAssistantStream = () => {
671
+ setStreamingContent("");
672
+ setStreamingReasoning("");
673
+ setStreamingTools([]);
674
+ setStreamingParts([]);
675
+ assistantContent = "";
676
+ assistantReasoning = "";
677
+ toolCalls.length = 0;
678
+ assistantParts.length = 0;
679
+ };
680
+ try {
681
+ for await (const event of agent.run(actualInput, args.cwd, { abortSignal: abortController.signal })) {
682
+ switch (event.type) {
683
+ case "text_delta":
684
+ assistantContent += event.content;
685
+ appendTextPart(assistantParts, event.content);
686
+ setStreamingContent(assistantContent);
687
+ syncStreamingParts();
688
+ break;
689
+ case "reasoning_delta":
690
+ assistantReasoning += event.content;
691
+ setStreamingReasoning(assistantReasoning);
692
+ break;
693
+ case "tool_call_start": {
694
+ // The LLM has begun emitting this tool call. Args are still
695
+ // streaming — render an empty-args placeholder so the user
696
+ // sees the tool the moment it appears in the assistant
697
+ // response, not after the full arg payload finishes.
698
+ if (!toolCalls.some((t) => t.id === event.id)) {
699
+ const toolCall = {
700
+ id: event.id,
701
+ name: event.name,
702
+ args: {},
703
+ startedAt: Date.now(),
704
+ };
705
+ toolCalls.push(toolCall);
706
+ appendToolPart(assistantParts, toolCall);
707
+ setStreamingTools([...toolCalls]);
708
+ syncStreamingParts();
709
+ }
710
+ break;
711
+ }
712
+ case "tool_call_delta": {
713
+ // Best-effort parse of the partial argument JSON to extract
714
+ // identifying fields (path, command, content, …). The buffer
715
+ // is incomplete JSON during streaming, so fall back to regex
716
+ // peeks on common string fields.
717
+ const tc = toolCalls.find((t) => t.id === event.id);
718
+ if (tc) {
719
+ tc.args = parsePartialArgs(event.arguments, tc.args);
720
+ setStreamingTools([...toolCalls]);
721
+ syncStreamingParts();
722
+ }
723
+ break;
724
+ }
725
+ case "tool_call_end": {
726
+ // Provider signaled args streaming is complete; agent will
727
+ // emit tool_start next. We don't need to do anything visual
728
+ // here — the placeholder is already in place and tool_start
729
+ // will refresh it with the canonical parsed args.
730
+ break;
731
+ }
732
+ case "tool_start": {
733
+ // Tool is about to execute. Upgrade the placeholder created
734
+ // by tool_call_start (or append if upstream skipped the
735
+ // streaming path).
736
+ const existing = toolCalls.find((t) => t.id === event.id);
737
+ if (existing) {
738
+ existing.args = event.args;
739
+ existing.startedAt = existing.startedAt ?? Date.now();
740
+ }
741
+ else {
742
+ const toolCall = {
743
+ id: event.id,
744
+ name: event.name,
745
+ args: event.args,
746
+ startedAt: Date.now(),
747
+ };
748
+ toolCalls.push(toolCall);
749
+ appendToolPart(assistantParts, toolCall);
750
+ }
751
+ setStreamingTools([...toolCalls]);
752
+ syncStreamingParts();
753
+ break;
754
+ }
755
+ case "tool_end": {
756
+ const tc = toolCalls.find((t) => t.id === event.id);
757
+ if (tc) {
758
+ tc.result = event.result.content;
759
+ tc.isError = event.result.isError;
760
+ tc.metadata = event.result.metadata;
761
+ setStreamingTools([...toolCalls]);
762
+ syncStreamingParts();
763
+ }
764
+ break;
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
+ }
781
+ case "todos_updated": {
782
+ setTodos(event.todos);
783
+ break;
784
+ }
785
+ case "mode_changed": {
786
+ setPermissionMode(event.mode);
787
+ sessionManager?.appendMarker("mode_switch", event.mode);
788
+ break;
789
+ }
790
+ case "turn_end": {
791
+ if (event.usage) {
792
+ setUsageTotals((totals) => ({
793
+ prompt: totals.prompt + event.usage.promptTokens,
794
+ completion: totals.completion + event.usage.completionTokens,
795
+ }));
796
+ }
797
+ if (event.willContinue) {
798
+ syncStreamingParts();
799
+ break;
800
+ }
801
+ commitAssistantMessage();
802
+ clearAssistantStream();
803
+ break;
804
+ }
805
+ }
806
+ }
807
+ }
808
+ catch (err) {
809
+ commitAssistantMessage();
810
+ if (err instanceof AgentAbortError || err?.name === "AbortError") {
811
+ updateDisplayMessages((prev) => [
812
+ ...prev,
813
+ withMessageKey({ role: "assistant", content: "Cancelled." }),
814
+ ]);
815
+ }
816
+ else {
817
+ updateDisplayMessages((prev) => [
818
+ ...prev,
819
+ withMessageKey({ role: "error", content: err.message }),
820
+ ]);
821
+ }
822
+ }
823
+ finally {
824
+ if (activeAbortRef.current === abortController)
825
+ activeAbortRef.current = null;
826
+ setIsRunning(false);
827
+ runStartRef.current = null;
828
+ setStreamingContent("");
829
+ setStreamingReasoning("");
830
+ setStreamingTools([]);
831
+ setStreamingParts([]);
832
+ }
833
+ };
834
+ // Slash commands and skill invocations drop any attached images —
835
+ // they're meant for pure command routing.
836
+ if (input.startsWith("/")) {
837
+ // Fast-path `/quit` and `/exit` before slash-registry / skill
838
+ // resolution. This guarantees a literal "/quit" always exits even if
839
+ // a skill or alias of the same name is later registered. The
840
+ // canonical handler still lives in slash-commands/commands.ts so
841
+ // `/help` and the slash menu can list it; both paths end up calling
842
+ // requestExit().
843
+ if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
844
+ requestExit();
845
+ return;
846
+ }
847
+ const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
848
+ if (skillInvocation) {
849
+ await runAgentInput(skillInvocation.actualPrompt, input);
850
+ return;
851
+ }
852
+ const { handled, result, inject } = await slashRegistry.execute(input, {
853
+ agent,
854
+ addMessage,
855
+ clearMessages,
856
+ cwd: args.cwd,
857
+ exit: () => { requestExit(); },
858
+ sessionManager,
859
+ createProvider: createProvider ?? (() => {
860
+ throw new Error("Provider creation not available");
861
+ }),
862
+ openPicker,
863
+ registry: safeRegistry,
864
+ skillRegistry: safeSkillRegistry,
865
+ bashAllowlist,
866
+ settingsManager,
867
+ lspService,
868
+ mcpManager,
869
+ flushMemory,
870
+ runMemoryCompaction,
871
+ runMemorySummary,
872
+ runMemoryRefresh,
873
+ getThemeMode: () => themeMode,
874
+ getResolvedTheme: () => themeResolved,
875
+ setThemeMode: applyThemeMode,
876
+ });
877
+ if (handled) {
878
+ if (agent.mode !== permissionMode) {
879
+ setPermissionMode(agent.mode);
880
+ }
881
+ if (result) {
882
+ addMessage("assistant", result);
883
+ }
884
+ if (inject) {
885
+ await runAgentInput(inject, input);
886
+ }
887
+ return;
888
+ }
889
+ }
890
+ const expansion = await expandAtMentions(input, args.cwd);
891
+ if (expansion.missing.length > 0) {
892
+ addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
893
+ }
894
+ for (const skip of expansion.skipped) {
895
+ addMessage("error", `Skipped @${skip.path}: ${skip.reason}`);
896
+ }
897
+ const agentInput = images.length > 0
898
+ ? [
899
+ ...(expansion.text ? [{ type: "text", text: expansion.text }] : []),
900
+ ...images.map((img) => ({
901
+ type: "image_url",
902
+ image_url: { url: img.dataUrl },
903
+ })),
904
+ ]
905
+ : expansion.text;
906
+ await runAgentInput(agentInput, input, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
907
+ }, [addMessage, agent, args.cwd, openPicker, createProvider, safeRegistry, safeSkillRegistry, updateDisplayMessages]);
908
+ const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
909
+ const keyTarget = keyProviderId
910
+ ? safeRegistry.getConfigured().find((p) => p.id === keyProviderId)
911
+ : safeRegistry.getDefault();
912
+ // Surface a pending approval as an inline badge on the matching tool row.
913
+ // ApprovalRequest does not carry a toolCallId today; matching is loose by
914
+ // type + the most identifying arg (path/command).
915
+ const approvalHint = pendingApproval
916
+ ? (() => {
917
+ const r = pendingApproval.request;
918
+ if (r.type === "bash")
919
+ return { toolName: "bash", command: r.command };
920
+ if (r.type === "edit")
921
+ return { toolName: "edit", path: r.path };
922
+ if (r.type === "write")
923
+ return { toolName: "write", path: r.path };
924
+ return null;
925
+ })()
926
+ : null;
927
+ const showWelcome = shouldShowWelcomeBanner({
928
+ messages,
929
+ startedWithVisibleHistory: startedWithVisibleHistoryRef.current,
930
+ });
931
+ const mcpStates = mcpManager?.getStates() ?? [];
932
+ const mcpConnectedCount = mcpStates.filter((state) => state.status.kind === "connected").length;
933
+ const hasAgentsFile = useMemo(() => existsSync(join(args.cwd, "AGENTS.md")) || existsSync(join(args.cwd, ".bubble", "AGENTS.md")), [args.cwd]);
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;
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
+ }) }))] }) }));
1013
+ }
1014
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1015
+ const GENERIC_PHRASES = [
1016
+ "mapping the workspace",
1017
+ "reading the room",
1018
+ "following the threads",
1019
+ "connecting the pieces",
1020
+ "sorting the context",
1021
+ "scanning the structure",
1022
+ "shaping the next step",
1023
+ "gathering signal",
1024
+ "checking the edges",
1025
+ "lining up the answer",
1026
+ "tracing the flow",
1027
+ "building the picture",
1028
+ "walking the graph",
1029
+ "collecting the clues",
1030
+ "framing the problem",
1031
+ "locating the source",
1032
+ "resolving the shape",
1033
+ "untangling the state",
1034
+ "comparing the paths",
1035
+ "narrowing the target",
1036
+ "tracking the changes",
1037
+ "reading the patterns",
1038
+ "weighing the options",
1039
+ "assembling the context",
1040
+ "following the signal",
1041
+ "checking the assumptions",
1042
+ "aligning the details",
1043
+ "testing the shape",
1044
+ "pulling the thread",
1045
+ "cleaning the edges",
1046
+ "refining the draft",
1047
+ "verifying the route",
1048
+ "making sense of it",
1049
+ "looking for leverage",
1050
+ "stitching the answer",
1051
+ "holding the thread",
1052
+ "distilling the noise",
1053
+ "finding the seam",
1054
+ "reading between the lines",
1055
+ "preparing the response",
1056
+ ];
1057
+ const TOOL_TARGET_PHRASES = {
1058
+ read: "reading files",
1059
+ write: "writing changes",
1060
+ edit: "patching files",
1061
+ grep: "searching the codebase",
1062
+ glob: "scanning paths",
1063
+ ls: "listing directories",
1064
+ bash: "running command",
1065
+ web_search: "searching the web",
1066
+ web_fetch: "fetching a page",
1067
+ task: "spawning subagent",
1068
+ };
1069
+ function formatTokensApprox(chars) {
1070
+ const tokens = Math.max(0, Math.round(chars / 4));
1071
+ if (tokens < 1000)
1072
+ return `${tokens}`;
1073
+ if (tokens < 10000)
1074
+ return `${(tokens / 1000).toFixed(1)}k`;
1075
+ return `${Math.round(tokens / 1000)}k`;
1076
+ }
1077
+ function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, runStartedAt, nowTick, }) {
1078
+ const theme = useTheme();
1079
+ const [frameIndex, setFrameIndex] = useState(0);
1080
+ const [idlePhrase, setIdlePhrase] = useState(() => GENERIC_PHRASES[0]);
1081
+ // Frame timer is independent of the agent state — keeps animation smooth.
1082
+ useEffect(() => {
1083
+ const t = setInterval(() => {
1084
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
1085
+ }, 100);
1086
+ return () => clearInterval(t);
1087
+ }, []);
1088
+ // Determine state: active tool > streaming text > streaming reasoning > idle
1089
+ const activeTool = [...tools].reverse().find((t) => !t.result);
1090
+ const state = activeTool
1091
+ ? "tool"
1092
+ : hasStreamingText
1093
+ ? "text"
1094
+ : hasStreamingReasoning
1095
+ ? "reasoning"
1096
+ : "idle";
1097
+ // Rotate idle phrases on a slower cadence; only matters in the idle state.
1098
+ useEffect(() => {
1099
+ if (state !== "idle")
1100
+ return;
1101
+ const t = setInterval(() => {
1102
+ setIdlePhrase((current) => {
1103
+ const candidates = GENERIC_PHRASES.filter((item) => item !== current);
1104
+ return candidates[Math.floor(Math.random() * candidates.length)] || current;
1105
+ });
1106
+ }, 1500);
1107
+ return () => clearInterval(t);
1108
+ }, [state]);
1109
+ let phrase;
1110
+ if (state === "tool" && activeTool) {
1111
+ phrase =
1112
+ TOOL_TARGET_PHRASES[activeTool.name] || `running ${activeTool.name}`;
1113
+ }
1114
+ else if (state === "text") {
1115
+ phrase = "writing the response";
1116
+ }
1117
+ else if (state === "reasoning") {
1118
+ phrase = "working through the request";
1119
+ }
1120
+ else {
1121
+ phrase = idlePhrase;
1122
+ }
1123
+ const elapsedSec = runStartedAt
1124
+ ? Math.max(0, Math.floor((nowTick - runStartedAt) / 1000))
1125
+ : 0;
1126
+ const elapsedText = elapsedSec > 0 ? `${elapsedSec}s` : "0s";
1127
+ const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
1128
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frameIndex] }), _jsxs(Text, { color: theme.muted, children: [" ", phrase, " "] }), _jsxs(Text, { color: theme.muted, dimColor: true, children: ["(", elapsedText, tokenText ? ` · ${tokenText}` : "", " \u00B7 esc\u00B7esc to interrupt)"] })] }));
1129
+ }