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