@bubblebrain-ai/bubble 0.0.7 → 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.
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +36 -3
- package/dist/context/budget.d.ts +1 -0
- package/dist/context/budget.js +1 -1
- package/dist/context/usage.d.ts +34 -0
- package/dist/context/usage.js +213 -0
- package/dist/diff-stats.d.ts +5 -0
- package/dist/diff-stats.js +21 -0
- package/dist/main.js +28 -4
- package/dist/mcp/transports.d.ts +1 -0
- package/dist/mcp/transports.js +8 -0
- package/dist/model-catalog.js +1 -1
- package/dist/orchestrator/default-hooks.js +6 -18
- package/dist/prompt/compose.js +2 -1
- package/dist/prompt/provider-prompts/kimi.js +3 -1
- package/dist/provider-registry.js +3 -3
- package/dist/provider-transform.d.ts +3 -1
- package/dist/provider-transform.js +15 -0
- package/dist/provider.d.ts +4 -1
- package/dist/provider.js +89 -4
- package/dist/reasoning-debug.d.ts +7 -0
- package/dist/reasoning-debug.js +30 -0
- package/dist/session-log.js +13 -2
- package/dist/session-types.d.ts +1 -1
- package/dist/slash-commands/commands.js +36 -2
- package/dist/tools/edit.js +5 -0
- package/dist/tools/file-state.d.ts +19 -0
- package/dist/tools/file-state.js +15 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +92 -11
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/run.js +93 -23
- package/dist/tui-ink/app.d.ts +43 -0
- package/dist/tui-ink/app.js +1016 -0
- package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
- package/dist/tui-ink/approval/approval-dialog.js +129 -0
- package/dist/tui-ink/approval/diff-view.d.ts +7 -0
- package/dist/tui-ink/approval/diff-view.js +43 -0
- package/dist/tui-ink/approval/select.d.ts +35 -0
- package/dist/tui-ink/approval/select.js +87 -0
- package/dist/tui-ink/code-highlight.d.ts +6 -0
- package/dist/tui-ink/code-highlight.js +94 -0
- package/dist/tui-ink/display-history.d.ts +38 -0
- package/dist/tui-ink/display-history.js +130 -0
- package/dist/tui-ink/edit-diff.d.ts +11 -0
- package/dist/tui-ink/edit-diff.js +52 -0
- package/dist/tui-ink/file-mentions.d.ts +29 -0
- package/dist/tui-ink/file-mentions.js +174 -0
- package/dist/tui-ink/footer.d.ts +19 -0
- package/dist/tui-ink/footer.js +44 -0
- package/dist/tui-ink/image-paste.d.ts +54 -0
- package/dist/tui-ink/image-paste.js +288 -0
- package/dist/tui-ink/input-box.d.ts +41 -0
- package/dist/tui-ink/input-box.js +637 -0
- package/dist/tui-ink/markdown.d.ts +38 -0
- package/dist/tui-ink/markdown.js +384 -0
- package/dist/tui-ink/message-list.d.ts +33 -0
- package/dist/tui-ink/message-list.js +571 -0
- package/dist/tui-ink/model-picker.d.ts +43 -0
- package/dist/tui-ink/model-picker.js +326 -0
- package/dist/tui-ink/plan-confirm.d.ts +7 -0
- package/dist/tui-ink/plan-confirm.js +104 -0
- package/dist/tui-ink/question-dialog.d.ts +8 -0
- package/dist/tui-ink/question-dialog.js +98 -0
- package/dist/tui-ink/recent-activity.d.ts +8 -0
- package/dist/tui-ink/recent-activity.js +71 -0
- package/dist/tui-ink/run.d.ts +33 -0
- package/dist/tui-ink/run.js +25 -0
- package/dist/tui-ink/theme.d.ts +37 -0
- package/dist/tui-ink/theme.js +42 -0
- package/dist/tui-ink/todos.d.ts +7 -0
- package/dist/tui-ink/todos.js +44 -0
- package/dist/tui-ink/trace-groups.d.ts +25 -0
- package/dist/tui-ink/trace-groups.js +310 -0
- package/dist/tui-ink/use-terminal-size.d.ts +4 -0
- package/dist/tui-ink/use-terminal-size.js +21 -0
- package/dist/tui-ink/welcome.d.ts +18 -0
- package/dist/tui-ink/welcome.js +119 -0
- package/dist/types.d.ts +4 -0
- 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
|
+
}
|