@bubblebrain-ai/bubble 0.0.6 → 0.0.8

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 (85) hide show
  1. package/dist/agent/execution-governor.d.ts +5 -13
  2. package/dist/agent/execution-governor.js +33 -142
  3. package/dist/agent.d.ts +6 -0
  4. package/dist/agent.js +36 -3
  5. package/dist/context/budget.d.ts +1 -0
  6. package/dist/context/budget.js +1 -1
  7. package/dist/context/usage.d.ts +34 -0
  8. package/dist/context/usage.js +213 -0
  9. package/dist/diff-stats.d.ts +5 -0
  10. package/dist/diff-stats.js +21 -0
  11. package/dist/main.js +83 -44
  12. package/dist/mcp/transports.d.ts +1 -0
  13. package/dist/mcp/transports.js +8 -0
  14. package/dist/model-catalog.js +1 -1
  15. package/dist/orchestrator/default-hooks.js +9 -33
  16. package/dist/prompt/compose.js +2 -1
  17. package/dist/prompt/provider-prompts/kimi.js +3 -1
  18. package/dist/prompt/reminders.d.ts +2 -1
  19. package/dist/prompt/reminders.js +4 -3
  20. package/dist/provider-registry.js +3 -3
  21. package/dist/provider-transform.d.ts +3 -1
  22. package/dist/provider-transform.js +15 -0
  23. package/dist/provider.d.ts +4 -1
  24. package/dist/provider.js +89 -4
  25. package/dist/reasoning-debug.d.ts +7 -0
  26. package/dist/reasoning-debug.js +30 -0
  27. package/dist/session-log.js +13 -2
  28. package/dist/session-types.d.ts +1 -1
  29. package/dist/slash-commands/commands.js +36 -19
  30. package/dist/tools/edit.js +5 -0
  31. package/dist/tools/file-state.d.ts +19 -0
  32. package/dist/tools/file-state.js +15 -0
  33. package/dist/tools/read.d.ts +1 -1
  34. package/dist/tools/read.js +92 -11
  35. package/dist/tui/escape-confirmation.d.ts +15 -0
  36. package/dist/tui/escape-confirmation.js +30 -0
  37. package/dist/tui/run.js +93 -23
  38. package/dist/tui-ink/app.d.ts +43 -0
  39. package/dist/tui-ink/app.js +1016 -0
  40. package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
  41. package/dist/tui-ink/approval/approval-dialog.js +129 -0
  42. package/dist/tui-ink/approval/diff-view.d.ts +7 -0
  43. package/dist/tui-ink/approval/diff-view.js +43 -0
  44. package/dist/tui-ink/approval/select.d.ts +35 -0
  45. package/dist/tui-ink/approval/select.js +87 -0
  46. package/dist/tui-ink/code-highlight.d.ts +6 -0
  47. package/dist/tui-ink/code-highlight.js +94 -0
  48. package/dist/tui-ink/display-history.d.ts +38 -0
  49. package/dist/tui-ink/display-history.js +130 -0
  50. package/dist/tui-ink/edit-diff.d.ts +11 -0
  51. package/dist/tui-ink/edit-diff.js +52 -0
  52. package/dist/tui-ink/file-mentions.d.ts +29 -0
  53. package/dist/tui-ink/file-mentions.js +174 -0
  54. package/dist/tui-ink/footer.d.ts +19 -0
  55. package/dist/tui-ink/footer.js +44 -0
  56. package/dist/tui-ink/image-paste.d.ts +54 -0
  57. package/dist/tui-ink/image-paste.js +288 -0
  58. package/dist/tui-ink/input-box.d.ts +41 -0
  59. package/dist/tui-ink/input-box.js +637 -0
  60. package/dist/tui-ink/markdown.d.ts +38 -0
  61. package/dist/tui-ink/markdown.js +384 -0
  62. package/dist/tui-ink/message-list.d.ts +33 -0
  63. package/dist/tui-ink/message-list.js +571 -0
  64. package/dist/tui-ink/model-picker.d.ts +43 -0
  65. package/dist/tui-ink/model-picker.js +326 -0
  66. package/dist/tui-ink/plan-confirm.d.ts +7 -0
  67. package/dist/tui-ink/plan-confirm.js +104 -0
  68. package/dist/tui-ink/question-dialog.d.ts +8 -0
  69. package/dist/tui-ink/question-dialog.js +98 -0
  70. package/dist/tui-ink/recent-activity.d.ts +8 -0
  71. package/dist/tui-ink/recent-activity.js +71 -0
  72. package/dist/tui-ink/run.d.ts +33 -0
  73. package/dist/tui-ink/run.js +25 -0
  74. package/dist/tui-ink/theme.d.ts +37 -0
  75. package/dist/tui-ink/theme.js +42 -0
  76. package/dist/tui-ink/todos.d.ts +7 -0
  77. package/dist/tui-ink/todos.js +44 -0
  78. package/dist/tui-ink/trace-groups.d.ts +25 -0
  79. package/dist/tui-ink/trace-groups.js +310 -0
  80. package/dist/tui-ink/use-terminal-size.d.ts +4 -0
  81. package/dist/tui-ink/use-terminal-size.js +21 -0
  82. package/dist/tui-ink/welcome.d.ts +18 -0
  83. package/dist/tui-ink/welcome.js +119 -0
  84. package/dist/types.d.ts +4 -0
  85. package/package.json +6 -1
@@ -0,0 +1,1016 @@
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 { theme } 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
+ /**
140
+ * Coerce a freshly-constructed DisplayMessage into one that carries a stable
141
+ * `key`. Centralizes the safety net so callers don't have to remember to call
142
+ * nextDisplayMessageKey on every push.
143
+ */
144
+ function withMessageKey(message) {
145
+ if (message.key)
146
+ return message;
147
+ const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
148
+ return { ...message, key: nextDisplayMessageKey(prefix) };
149
+ }
150
+ export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }) {
151
+ const { exit } = useApp();
152
+ const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
153
+ const [isRunning, setIsRunning] = useState(false);
154
+ const [streamingContent, setStreamingContent] = useState("");
155
+ const [streamingReasoning, setStreamingReasoning] = useState("");
156
+ const [streamingTools, setStreamingTools] = useState([]);
157
+ const [streamingParts, setStreamingParts] = useState([]);
158
+ const [usageTotals, setUsageTotals] = useState({ prompt: 0, completion: 0 });
159
+ const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
160
+ const [permissionMode, setPermissionMode] = useState(agent.mode);
161
+ const [todos, setTodos] = useState(() => agent.getTodos());
162
+ const [pendingPlan, setPendingPlan] = useState(null);
163
+ const [pendingApproval, setPendingApproval] = useState(null);
164
+ const [pendingQuestion, setPendingQuestion] = useState(null);
165
+ const [pickerMode, setPickerMode] = useState(null);
166
+ const [keyProviderId, setKeyProviderId] = useState(null);
167
+ const [verboseTrace, setVerboseTrace] = useState(false);
168
+ const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
169
+ const { columns: terminalColumns } = useTerminalSize();
170
+ const activeAbortRef = useRef(null);
171
+ const exitRequestedRef = useRef(false);
172
+ // 1Hz tick used to refresh elapsed counters on in-progress tool rows and
173
+ // on the WaitingIndicator. Only ticks while the agent is running so we
174
+ // don't churn renders at idle.
175
+ const [nowTick, setNowTick] = useState(() => Date.now());
176
+ // Timestamp of when the current agent run started — drives elapsed display
177
+ // on the WaitingIndicator.
178
+ const runStartRef = useRef(null);
179
+ // Mark the moment the run started; flips back to null in the finally block.
180
+ useEffect(() => {
181
+ if (!isRunning)
182
+ return;
183
+ setNowTick(Date.now());
184
+ const t = setInterval(() => setNowTick(Date.now()), 1000);
185
+ return () => clearInterval(t);
186
+ }, [isRunning]);
187
+ const userConfig = new UserConfig();
188
+ const safeRegistry = registry ?? new ProviderRegistry(userConfig);
189
+ const safeSkillRegistry = skillRegistry ?? new SkillRegistry({
190
+ cwd: args.cwd,
191
+ skillPaths: userConfig.getSkillPaths(),
192
+ });
193
+ const requestExit = useCallback(() => {
194
+ if (exitRequestedRef.current)
195
+ return;
196
+ exitRequestedRef.current = true;
197
+ // Cancel any in-flight agent run first so its tools / network calls
198
+ // don't keep emitting text after Ink unmounts and corrupt the
199
+ // restored shell prompt.
200
+ if (activeAbortRef.current) {
201
+ try {
202
+ activeAbortRef.current.abort(new AgentAbortError("Exiting Bubble."));
203
+ }
204
+ catch {
205
+ // ignore — abort is best effort during shutdown
206
+ }
207
+ activeAbortRef.current = null;
208
+ }
209
+ void (async () => {
210
+ let flushError = null;
211
+ if (flushMemory) {
212
+ // Bound the flush so a stuck LLM/network call cannot trap the TUI.
213
+ let timer;
214
+ try {
215
+ await Promise.race([
216
+ flushMemory(),
217
+ new Promise((_, reject) => {
218
+ timer = setTimeout(() => reject(new Error("flushMemory timed out after 3s")), 3000);
219
+ }),
220
+ ]);
221
+ }
222
+ catch (err) {
223
+ flushError = err;
224
+ }
225
+ finally {
226
+ if (timer)
227
+ clearTimeout(timer);
228
+ }
229
+ }
230
+ // Hand off to Ink. Ink's render instance owns TTY teardown (raw mode,
231
+ // cursor, alt-screen); doing it ourselves here races with that and
232
+ // leaves the terminal in odd states. run.tsx awaits waitUntilExit()
233
+ // and then main.ts handles the rest.
234
+ exit();
235
+ // Surface flush failures *after* Ink has restored the screen so the
236
+ // warning lands on the real shell instead of being clobbered.
237
+ if (flushError) {
238
+ const message = flushError instanceof Error ? flushError.message : String(flushError);
239
+ process.nextTick(() => {
240
+ process.stderr.write(`warning: failed to flush memory on exit: ${message}\n`);
241
+ });
242
+ }
243
+ onExit?.();
244
+ })();
245
+ }, [exit, flushMemory, onExit]);
246
+ useEffect(() => {
247
+ if (!planHandlerRef)
248
+ return;
249
+ planHandlerRef.current = (plan) => new Promise((resolve) => {
250
+ setPendingPlan({ plan, resolve });
251
+ });
252
+ return () => {
253
+ if (planHandlerRef.current) {
254
+ planHandlerRef.current = undefined;
255
+ }
256
+ };
257
+ }, [planHandlerRef]);
258
+ useEffect(() => {
259
+ if (!approvalHandlerRef)
260
+ return;
261
+ approvalHandlerRef.current = (request) => new Promise((resolve) => {
262
+ setPendingApproval({ request, resolve });
263
+ });
264
+ return () => {
265
+ if (approvalHandlerRef.current) {
266
+ approvalHandlerRef.current = undefined;
267
+ }
268
+ };
269
+ }, [approvalHandlerRef]);
270
+ useEffect(() => {
271
+ if (!questionController)
272
+ return;
273
+ const syncFirstPending = () => {
274
+ setPendingQuestion((current) => current ?? questionController.list()[0] ?? null);
275
+ };
276
+ const unsubscribe = questionController.subscribe((event) => {
277
+ if (event.type === "asked") {
278
+ setPendingQuestion(event.request);
279
+ return;
280
+ }
281
+ setPendingQuestion((current) => current?.id === event.request.id ? null : current);
282
+ setTimeout(syncFirstPending, 0);
283
+ });
284
+ syncFirstPending();
285
+ return unsubscribe;
286
+ }, [questionController]);
287
+ const rebuildSystemPrompt = useCallback((overrides) => {
288
+ const modelParts = agent.model.includes(":")
289
+ ? agent.model.split(":")
290
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
291
+ const providerId = modelParts[0];
292
+ agent.setSystemPrompt(buildSystemPrompt({
293
+ agentName: "Bubble",
294
+ configuredProvider: providerId,
295
+ configuredModel: displayModel(agent.model),
296
+ configuredModelId: agent.model,
297
+ thinkingLevel: overrides?.thinkingLevel ?? agent.thinking,
298
+ mode: overrides?.mode ?? agent.mode,
299
+ workingDir: args.cwd,
300
+ skills: safeSkillRegistry?.summaries() ?? [],
301
+ }));
302
+ }, [agent, args.cwd, safeRegistry, safeSkillRegistry]);
303
+ useInput((input, key) => {
304
+ if (pendingPlan || pendingApproval || pendingQuestion)
305
+ return;
306
+ if (key.ctrl && input === "o" && !pickerMode) {
307
+ setVerboseTrace((v) => !v);
308
+ return;
309
+ }
310
+ // Ctrl+R: cycle thinking level (formerly Shift+Tab)
311
+ if (key.ctrl && input === "r" && !pickerMode) {
312
+ const modelParts = agent.model.includes(":")
313
+ ? agent.model.split(":")
314
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
315
+ const providerId = modelParts[0];
316
+ const modelId = modelParts.slice(1).join(":");
317
+ const availableLevels = getAvailableThinkingLevels(providerId, modelId);
318
+ const currentLevel = normalizeThinkingLevel(agent.thinking, availableLevels);
319
+ const currentIndex = availableLevels.indexOf(currentLevel);
320
+ const nextLevel = availableLevels[(currentIndex + 1) % availableLevels.length];
321
+ agent.thinking = nextLevel;
322
+ rebuildSystemPrompt({ thinkingLevel: nextLevel });
323
+ userConfig.setDefaultThinkingLevel(nextLevel);
324
+ setThinkingLevel(nextLevel);
325
+ sessionManager?.setMetadata({ model: agent.model, thinkingLevel: nextLevel, reasoningEffort: nextLevel });
326
+ sessionManager?.appendMarker("thinking_level_switch", nextLevel);
327
+ return;
328
+ }
329
+ // Shift+Tab: cycle through permission modes (default → acceptEdits → plan
330
+ // → [bypassPermissions if enabled] → default). Agent.setMode injects a
331
+ // <system-reminder>, so we do not rebuild the cache-friendly system prompt here.
332
+ if (key.tab && key.shift && !pickerMode) {
333
+ const nextMode = getNextPermissionMode(agent.mode);
334
+ agent.setMode(nextMode);
335
+ setPermissionMode(nextMode);
336
+ sessionManager?.appendMarker("mode_switch", nextMode);
337
+ return;
338
+ }
339
+ if (key.escape && !pickerMode) {
340
+ if (isRunning && activeAbortRef.current) {
341
+ activeAbortRef.current.abort(new AgentAbortError("Agent run cancelled by user."));
342
+ return;
343
+ }
344
+ }
345
+ });
346
+ const updateDisplayMessages = useCallback((updater) => {
347
+ setMessages((prev) => compactDisplayMessages(updater(prev).map(withMessageKey)));
348
+ }, []);
349
+ const addMessage = useCallback((role, content) => {
350
+ updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
351
+ }, [updateDisplayMessages]);
352
+ const clearMessages = useCallback(() => {
353
+ setMessages([]);
354
+ }, []);
355
+ const openPicker = useCallback((mode, providerId) => {
356
+ if (mode === "key") {
357
+ setKeyProviderId(providerId ?? null);
358
+ }
359
+ setPickerMode(mode);
360
+ }, []);
361
+ const handleModelSelect = useCallback((model) => {
362
+ const run = async () => {
363
+ agent.model = model;
364
+ const decoded = model.includes(":")
365
+ ? model.split(":")
366
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", model];
367
+ const providerId = decoded[0];
368
+ await safeRegistry.prepareProvider(providerId);
369
+ const provider = safeRegistry.getConfigured().find((item) => item.id === providerId);
370
+ if (!provider?.apiKey || !createProvider) {
371
+ addMessage("error", `Provider ${providerId} is not configured or has no active credentials.`);
372
+ setPickerMode(null);
373
+ return;
374
+ }
375
+ const modelId = model.includes(":") ? model.split(":").slice(1).join(":") : model;
376
+ agent.thinking = normalizeThinkingLevel(agent.thinking || getDefaultThinkingLevel(providerId, modelId), getAvailableThinkingLevels(providerId, modelId));
377
+ agent.setProvider(createProvider(providerId, provider.apiKey, provider.baseURL));
378
+ agent.providerId = providerId;
379
+ agent.setSystemPrompt(buildSystemPrompt({
380
+ agentName: "Bubble",
381
+ configuredProvider: providerId,
382
+ configuredModel: displayModel(model),
383
+ configuredModelId: model,
384
+ thinkingLevel: agent.thinking,
385
+ workingDir: args.cwd,
386
+ skills: safeSkillRegistry?.summaries() ?? [],
387
+ }));
388
+ userConfig.pushRecentModel(model);
389
+ setThinkingLevel(agent.thinking);
390
+ sessionManager?.setMetadata({ model, thinkingLevel: agent.thinking, reasoningEffort: agent.thinking });
391
+ sessionManager?.appendMarker("model_switch", model);
392
+ addMessage("assistant", `Model switched to ${displayModel(model)}.`);
393
+ setPickerMode(null);
394
+ };
395
+ void run();
396
+ }, [agent, addMessage, sessionManager, userConfig, safeRegistry, createProvider]);
397
+ const handleProviderSelect = useCallback(async (providerId) => {
398
+ await safeRegistry.prepareProvider(providerId);
399
+ const configured = safeRegistry.getConfigured();
400
+ const p = configured.find((x) => x.id === providerId);
401
+ const builtin = BUILTIN_PROVIDERS.find((x) => x.id === providerId);
402
+ if (!p && !builtin) {
403
+ addMessage("error", `Provider ${providerId} not found.`);
404
+ setPickerMode(null);
405
+ return;
406
+ }
407
+ if (!p?.apiKey) {
408
+ if (!p && builtin) {
409
+ safeRegistry.addProvider(providerId, "");
410
+ }
411
+ safeRegistry.setDefault(providerId);
412
+ setKeyProviderId(providerId);
413
+ setPickerMode("key");
414
+ return;
415
+ }
416
+ safeRegistry.setDefault(providerId);
417
+ agent.setProvider(createProvider(providerId, p.apiKey, p.baseURL));
418
+ agent.providerId = providerId;
419
+ addMessage("assistant", `Switched to provider ${p.name}. Use /model to pick a model.`);
420
+ setPickerMode(null);
421
+ }, [addMessage, agent, createProvider, safeRegistry]);
422
+ const handleProviderAddSelect = useCallback((providerId) => {
423
+ const ok = safeRegistry.addProvider(providerId, "");
424
+ if (!ok) {
425
+ addMessage("error", `Provider ${providerId} could not be added.`);
426
+ setPickerMode(null);
427
+ return;
428
+ }
429
+ safeRegistry.setDefault(providerId);
430
+ setKeyProviderId(providerId);
431
+ setPickerMode("key");
432
+ }, [addMessage, safeRegistry]);
433
+ const handleLoginProviderSelect = useCallback(async (providerId) => {
434
+ setPickerMode(null);
435
+ const command = `/login ${providerId}`;
436
+ const { handled, result } = await slashRegistry.execute(command, {
437
+ agent,
438
+ addMessage,
439
+ clearMessages,
440
+ cwd: args.cwd,
441
+ exit: () => { requestExit(); },
442
+ sessionManager,
443
+ createProvider: createProvider ?? (() => {
444
+ throw new Error("Provider creation not available");
445
+ }),
446
+ openPicker,
447
+ registry: safeRegistry,
448
+ skillRegistry: safeSkillRegistry,
449
+ bashAllowlist,
450
+ settingsManager,
451
+ lspService,
452
+ mcpManager,
453
+ flushMemory,
454
+ runMemoryCompaction,
455
+ runMemorySummary,
456
+ runMemoryRefresh,
457
+ });
458
+ if (handled && result) {
459
+ addMessage("assistant", result);
460
+ }
461
+ }, [agent, addMessage, clearMessages, createProvider, exit, openPicker, safeRegistry, sessionManager]);
462
+ const handleLogoutProviderSelect = useCallback(async (providerId) => {
463
+ setPickerMode(null);
464
+ const command = `/logout ${providerId}`;
465
+ const { handled, result } = await slashRegistry.execute(command, {
466
+ agent,
467
+ addMessage,
468
+ clearMessages,
469
+ cwd: args.cwd,
470
+ exit: () => { requestExit(); },
471
+ sessionManager,
472
+ createProvider: createProvider ?? (() => {
473
+ throw new Error("Provider creation not available");
474
+ }),
475
+ openPicker,
476
+ registry: safeRegistry,
477
+ skillRegistry: safeSkillRegistry,
478
+ bashAllowlist,
479
+ settingsManager,
480
+ lspService,
481
+ mcpManager,
482
+ flushMemory,
483
+ runMemoryCompaction,
484
+ runMemorySummary,
485
+ runMemoryRefresh,
486
+ });
487
+ if (handled && result) {
488
+ addMessage("assistant", result);
489
+ }
490
+ }, [agent, addMessage, clearMessages, createProvider, exit, openPicker, safeRegistry, sessionManager]);
491
+ const handleKeySubmit = useCallback((key) => {
492
+ const targetId = keyProviderId || safeRegistry.getDefault()?.id;
493
+ if (!targetId) {
494
+ addMessage("error", "No provider selected.");
495
+ setPickerMode(null);
496
+ setKeyProviderId(null);
497
+ return;
498
+ }
499
+ safeRegistry.updateProviderKey(targetId, key);
500
+ const p = safeRegistry.getConfigured().find((x) => x.id === targetId);
501
+ if (p && createProvider) {
502
+ agent.setProvider(createProvider(targetId, key, p.baseURL));
503
+ agent.providerId = targetId;
504
+ }
505
+ addMessage("assistant", `API key updated for ${p?.name || targetId} to ${maskKey(key)}.`);
506
+ setPickerMode(null);
507
+ setKeyProviderId(null);
508
+ }, [addMessage, agent, createProvider, keyProviderId, safeRegistry]);
509
+ const handleSubmit = useCallback(async (payload) => {
510
+ const normalized = typeof payload === "string" ? { text: payload, images: [] } : payload;
511
+ const input = normalized.text;
512
+ const images = normalized.images;
513
+ if (!input.trim() && images.length === 0)
514
+ return;
515
+ const runAgentInput = async (actualInput, displayInput, attachedImages = []) => {
516
+ const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
517
+ const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
518
+ if (!hasActiveProvider) {
519
+ addMessage("error", "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.");
520
+ return;
521
+ }
522
+ if (!agent.model) {
523
+ addMessage("error", "No model selected. Use /model after /login or provider setup.");
524
+ return;
525
+ }
526
+ const displayContent = attachedImages.length > 0
527
+ ? `${displayInput}${displayInput ? "\n" : ""}${attachedImages
528
+ .map((img, i) => `[image${attachedImages.length > 1 ? ` ${i + 1}` : ""}: ${img.filename ?? "clipboard"} · ${Math.max(1, Math.round(img.bytes / 1024))}KB]`)
529
+ .join(" ")}`
530
+ : displayInput;
531
+ updateDisplayMessages((prev) => [
532
+ ...prev,
533
+ withMessageKey({ role: "user", content: displayContent }),
534
+ ]);
535
+ setIsRunning(true);
536
+ runStartRef.current = Date.now();
537
+ setStreamingContent("");
538
+ setStreamingReasoning("");
539
+ setStreamingTools([]);
540
+ setStreamingParts([]);
541
+ let assistantContent = "";
542
+ let assistantReasoning = "";
543
+ const toolCalls = [];
544
+ const assistantParts = [];
545
+ const abortController = new AbortController();
546
+ activeAbortRef.current = abortController;
547
+ const syncStreamingParts = () => {
548
+ setStreamingParts(snapshotDisplayParts(assistantParts));
549
+ };
550
+ const hasAssistantOutput = () => (!!assistantContent ||
551
+ !!assistantReasoning ||
552
+ toolCalls.length > 0 ||
553
+ assistantParts.length > 0);
554
+ const commitAssistantMessage = () => {
555
+ if (!hasAssistantOutput())
556
+ return;
557
+ const currentParts = snapshotDisplayParts(assistantParts);
558
+ const currentToolCalls = [...toolCalls];
559
+ const partContent = assistantContent || contentFromParts(currentParts);
560
+ const partToolCalls = currentToolCalls.length > 0
561
+ ? currentToolCalls
562
+ : toolCallsFromParts(currentParts);
563
+ const msg = {
564
+ key: nextDisplayMessageKey("asst"),
565
+ role: "assistant",
566
+ content: partContent,
567
+ };
568
+ if (assistantReasoning) {
569
+ msg.reasoning = assistantReasoning;
570
+ }
571
+ if (partToolCalls.length > 0) {
572
+ msg.toolCalls = partToolCalls;
573
+ }
574
+ if (currentParts.length > 0) {
575
+ msg.parts = currentParts;
576
+ }
577
+ updateDisplayMessages((prev) => [...prev, msg]);
578
+ };
579
+ const clearAssistantStream = () => {
580
+ setStreamingContent("");
581
+ setStreamingReasoning("");
582
+ setStreamingTools([]);
583
+ setStreamingParts([]);
584
+ assistantContent = "";
585
+ assistantReasoning = "";
586
+ toolCalls.length = 0;
587
+ assistantParts.length = 0;
588
+ };
589
+ try {
590
+ for await (const event of agent.run(actualInput, args.cwd, { abortSignal: abortController.signal })) {
591
+ switch (event.type) {
592
+ case "text_delta":
593
+ assistantContent += event.content;
594
+ appendTextPart(assistantParts, event.content);
595
+ setStreamingContent(assistantContent);
596
+ syncStreamingParts();
597
+ break;
598
+ case "reasoning_delta":
599
+ assistantReasoning += event.content;
600
+ setStreamingReasoning(assistantReasoning);
601
+ break;
602
+ case "tool_call_start": {
603
+ // The LLM has begun emitting this tool call. Args are still
604
+ // streaming — render an empty-args placeholder so the user
605
+ // sees the tool the moment it appears in the assistant
606
+ // response, not after the full arg payload finishes.
607
+ if (!toolCalls.some((t) => t.id === event.id)) {
608
+ const toolCall = {
609
+ id: event.id,
610
+ name: event.name,
611
+ args: {},
612
+ startedAt: Date.now(),
613
+ };
614
+ toolCalls.push(toolCall);
615
+ appendToolPart(assistantParts, toolCall);
616
+ setStreamingTools([...toolCalls]);
617
+ syncStreamingParts();
618
+ }
619
+ break;
620
+ }
621
+ case "tool_call_delta": {
622
+ // Best-effort parse of the partial argument JSON to extract
623
+ // identifying fields (path, command, content, …). The buffer
624
+ // is incomplete JSON during streaming, so fall back to regex
625
+ // peeks on common string fields.
626
+ const tc = toolCalls.find((t) => t.id === event.id);
627
+ if (tc) {
628
+ tc.args = parsePartialArgs(event.arguments, tc.args);
629
+ setStreamingTools([...toolCalls]);
630
+ syncStreamingParts();
631
+ }
632
+ break;
633
+ }
634
+ case "tool_call_end": {
635
+ // Provider signaled args streaming is complete; agent will
636
+ // emit tool_start next. We don't need to do anything visual
637
+ // here — the placeholder is already in place and tool_start
638
+ // will refresh it with the canonical parsed args.
639
+ break;
640
+ }
641
+ case "tool_start": {
642
+ // Tool is about to execute. Upgrade the placeholder created
643
+ // by tool_call_start (or append if upstream skipped the
644
+ // streaming path).
645
+ const existing = toolCalls.find((t) => t.id === event.id);
646
+ if (existing) {
647
+ existing.args = event.args;
648
+ existing.startedAt = existing.startedAt ?? Date.now();
649
+ }
650
+ else {
651
+ const toolCall = {
652
+ id: event.id,
653
+ name: event.name,
654
+ args: event.args,
655
+ startedAt: Date.now(),
656
+ };
657
+ toolCalls.push(toolCall);
658
+ appendToolPart(assistantParts, toolCall);
659
+ }
660
+ setStreamingTools([...toolCalls]);
661
+ syncStreamingParts();
662
+ break;
663
+ }
664
+ case "tool_end": {
665
+ const tc = toolCalls.find((t) => t.id === event.id);
666
+ if (tc) {
667
+ tc.result = event.result.content;
668
+ tc.isError = event.result.isError;
669
+ tc.metadata = event.result.metadata;
670
+ setStreamingTools([...toolCalls]);
671
+ syncStreamingParts();
672
+ }
673
+ break;
674
+ }
675
+ case "todos_updated": {
676
+ setTodos(event.todos);
677
+ break;
678
+ }
679
+ case "mode_changed": {
680
+ setPermissionMode(event.mode);
681
+ sessionManager?.appendMarker("mode_switch", event.mode);
682
+ break;
683
+ }
684
+ case "turn_end": {
685
+ if (event.usage) {
686
+ setUsageTotals((totals) => ({
687
+ prompt: totals.prompt + event.usage.promptTokens,
688
+ completion: totals.completion + event.usage.completionTokens,
689
+ }));
690
+ }
691
+ if (event.willContinue) {
692
+ syncStreamingParts();
693
+ break;
694
+ }
695
+ commitAssistantMessage();
696
+ clearAssistantStream();
697
+ break;
698
+ }
699
+ }
700
+ }
701
+ }
702
+ catch (err) {
703
+ commitAssistantMessage();
704
+ if (err instanceof AgentAbortError || err?.name === "AbortError") {
705
+ updateDisplayMessages((prev) => [
706
+ ...prev,
707
+ withMessageKey({ role: "assistant", content: "Cancelled." }),
708
+ ]);
709
+ }
710
+ else {
711
+ updateDisplayMessages((prev) => [
712
+ ...prev,
713
+ withMessageKey({ role: "error", content: err.message }),
714
+ ]);
715
+ }
716
+ }
717
+ finally {
718
+ if (activeAbortRef.current === abortController)
719
+ activeAbortRef.current = null;
720
+ setIsRunning(false);
721
+ runStartRef.current = null;
722
+ setStreamingContent("");
723
+ setStreamingReasoning("");
724
+ setStreamingTools([]);
725
+ setStreamingParts([]);
726
+ }
727
+ };
728
+ // Slash commands and skill invocations drop any attached images —
729
+ // they're meant for pure command routing.
730
+ if (input.startsWith("/")) {
731
+ // Fast-path `/quit` and `/exit` before slash-registry / skill
732
+ // resolution. This guarantees a literal "/quit" always exits even if
733
+ // a skill or alias of the same name is later registered. The
734
+ // canonical handler still lives in slash-commands/commands.ts so
735
+ // `/help` and the slash menu can list it; both paths end up calling
736
+ // requestExit().
737
+ if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
738
+ requestExit();
739
+ return;
740
+ }
741
+ const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
742
+ if (skillInvocation) {
743
+ await runAgentInput(skillInvocation.actualPrompt, input);
744
+ return;
745
+ }
746
+ const { handled, result, inject } = await slashRegistry.execute(input, {
747
+ agent,
748
+ addMessage,
749
+ clearMessages,
750
+ cwd: args.cwd,
751
+ exit: () => { requestExit(); },
752
+ sessionManager,
753
+ createProvider: createProvider ?? (() => {
754
+ throw new Error("Provider creation not available");
755
+ }),
756
+ openPicker,
757
+ registry: safeRegistry,
758
+ skillRegistry: safeSkillRegistry,
759
+ bashAllowlist,
760
+ settingsManager,
761
+ lspService,
762
+ mcpManager,
763
+ flushMemory,
764
+ runMemoryCompaction,
765
+ runMemorySummary,
766
+ runMemoryRefresh,
767
+ });
768
+ if (handled) {
769
+ if (agent.mode !== permissionMode) {
770
+ setPermissionMode(agent.mode);
771
+ }
772
+ if (result) {
773
+ addMessage("assistant", result);
774
+ }
775
+ if (inject) {
776
+ await runAgentInput(inject, input);
777
+ }
778
+ return;
779
+ }
780
+ }
781
+ const expansion = await expandAtMentions(input, args.cwd);
782
+ if (expansion.missing.length > 0) {
783
+ addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
784
+ }
785
+ for (const skip of expansion.skipped) {
786
+ addMessage("error", `Skipped @${skip.path}: ${skip.reason}`);
787
+ }
788
+ const agentInput = images.length > 0
789
+ ? [
790
+ ...(expansion.text ? [{ type: "text", text: expansion.text }] : []),
791
+ ...images.map((img) => ({
792
+ type: "image_url",
793
+ image_url: { url: img.dataUrl },
794
+ })),
795
+ ]
796
+ : expansion.text;
797
+ await runAgentInput(agentInput, input, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
798
+ }, [addMessage, agent, args.cwd, openPicker, createProvider, safeRegistry, safeSkillRegistry, updateDisplayMessages]);
799
+ const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
800
+ const keyTarget = keyProviderId
801
+ ? safeRegistry.getConfigured().find((p) => p.id === keyProviderId)
802
+ : safeRegistry.getDefault();
803
+ // Surface a pending approval as an inline badge on the matching tool row.
804
+ // ApprovalRequest does not carry a toolCallId today; matching is loose by
805
+ // type + the most identifying arg (path/command).
806
+ const approvalHint = pendingApproval
807
+ ? (() => {
808
+ const r = pendingApproval.request;
809
+ if (r.type === "bash")
810
+ return { toolName: "bash", command: r.command };
811
+ if (r.type === "edit")
812
+ return { toolName: "edit", path: r.path };
813
+ if (r.type === "write")
814
+ return { toolName: "write", path: r.path };
815
+ return null;
816
+ })()
817
+ : null;
818
+ const showWelcome = shouldShowWelcomeBanner({
819
+ messages,
820
+ startedWithVisibleHistory: startedWithVisibleHistoryRef.current,
821
+ });
822
+ const mcpStates = mcpManager?.getStates() ?? [];
823
+ const mcpConnectedCount = mcpStates.filter((state) => state.status.kind === "connected").length;
824
+ const hasAgentsFile = useMemo(() => existsSync(join(args.cwd, "AGENTS.md")) || existsSync(join(args.cwd, ".bubble", "AGENTS.md")), [args.cwd]);
825
+ 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
+ }) })] }));
901
+ }
902
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
903
+ const GENERIC_PHRASES = [
904
+ "mapping the workspace",
905
+ "reading the room",
906
+ "following the threads",
907
+ "connecting the pieces",
908
+ "sorting the context",
909
+ "scanning the structure",
910
+ "shaping the next step",
911
+ "gathering signal",
912
+ "checking the edges",
913
+ "lining up the answer",
914
+ "tracing the flow",
915
+ "building the picture",
916
+ "walking the graph",
917
+ "collecting the clues",
918
+ "framing the problem",
919
+ "locating the source",
920
+ "resolving the shape",
921
+ "untangling the state",
922
+ "comparing the paths",
923
+ "narrowing the target",
924
+ "tracking the changes",
925
+ "reading the patterns",
926
+ "weighing the options",
927
+ "assembling the context",
928
+ "following the signal",
929
+ "checking the assumptions",
930
+ "aligning the details",
931
+ "testing the shape",
932
+ "pulling the thread",
933
+ "cleaning the edges",
934
+ "refining the draft",
935
+ "verifying the route",
936
+ "making sense of it",
937
+ "looking for leverage",
938
+ "stitching the answer",
939
+ "holding the thread",
940
+ "distilling the noise",
941
+ "finding the seam",
942
+ "reading between the lines",
943
+ "preparing the response",
944
+ ];
945
+ const TOOL_TARGET_PHRASES = {
946
+ read: "reading files",
947
+ write: "writing changes",
948
+ edit: "patching files",
949
+ grep: "searching the codebase",
950
+ glob: "scanning paths",
951
+ ls: "listing directories",
952
+ bash: "running command",
953
+ web_search: "searching the web",
954
+ web_fetch: "fetching a page",
955
+ task: "spawning subagent",
956
+ };
957
+ function formatTokensApprox(chars) {
958
+ const tokens = Math.max(0, Math.round(chars / 4));
959
+ if (tokens < 1000)
960
+ return `${tokens}`;
961
+ if (tokens < 10000)
962
+ return `${(tokens / 1000).toFixed(1)}k`;
963
+ return `${Math.round(tokens / 1000)}k`;
964
+ }
965
+ function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, runStartedAt, nowTick, }) {
966
+ const [frameIndex, setFrameIndex] = useState(0);
967
+ const [idlePhrase, setIdlePhrase] = useState(() => GENERIC_PHRASES[0]);
968
+ // Frame timer is independent of the agent state — keeps animation smooth.
969
+ useEffect(() => {
970
+ const t = setInterval(() => {
971
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
972
+ }, 100);
973
+ return () => clearInterval(t);
974
+ }, []);
975
+ // Determine state: active tool > streaming text > streaming reasoning > idle
976
+ const activeTool = [...tools].reverse().find((t) => !t.result);
977
+ const state = activeTool
978
+ ? "tool"
979
+ : hasStreamingText
980
+ ? "text"
981
+ : hasStreamingReasoning
982
+ ? "reasoning"
983
+ : "idle";
984
+ // Rotate idle phrases on a slower cadence; only matters in the idle state.
985
+ useEffect(() => {
986
+ if (state !== "idle")
987
+ return;
988
+ const t = setInterval(() => {
989
+ setIdlePhrase((current) => {
990
+ const candidates = GENERIC_PHRASES.filter((item) => item !== current);
991
+ return candidates[Math.floor(Math.random() * candidates.length)] || current;
992
+ });
993
+ }, 1500);
994
+ return () => clearInterval(t);
995
+ }, [state]);
996
+ let phrase;
997
+ if (state === "tool" && activeTool) {
998
+ phrase =
999
+ TOOL_TARGET_PHRASES[activeTool.name] || `running ${activeTool.name}`;
1000
+ }
1001
+ else if (state === "text") {
1002
+ phrase = "writing the response";
1003
+ }
1004
+ else if (state === "reasoning") {
1005
+ phrase = "working through the request";
1006
+ }
1007
+ else {
1008
+ phrase = idlePhrase;
1009
+ }
1010
+ const elapsedSec = runStartedAt
1011
+ ? Math.max(0, Math.floor((nowTick - runStartedAt) / 1000))
1012
+ : 0;
1013
+ const elapsedText = elapsedSec > 0 ? `${elapsedSec}s` : "0s";
1014
+ const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
1015
+ 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)"] })] }));
1016
+ }