@bubblebrain-ai/bubble 0.0.14 → 0.0.15
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/cli.d.ts +3 -1
- package/dist/cli.js +12 -0
- package/dist/main.js +13 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +87 -35
- package/dist/update/index.d.ts +46 -0
- package/dist/update/index.js +240 -0
- package/package.json +1 -1
package/dist/cli.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* CLI argument parsing.
|
|
3
3
|
*/
|
|
4
4
|
import type { PermissionMode, ThinkingLevel } from "./types.js";
|
|
5
|
-
export type CliCommand = "default" | "serve";
|
|
5
|
+
export type CliCommand = "default" | "serve" | "update";
|
|
6
6
|
export interface CliArgs {
|
|
7
7
|
command: CliCommand;
|
|
8
8
|
model?: string;
|
|
@@ -22,6 +22,8 @@ export interface CliArgs {
|
|
|
22
22
|
killOld?: boolean;
|
|
23
23
|
/** `serve` subcommand: connect then exit. */
|
|
24
24
|
dryRun?: boolean;
|
|
25
|
+
/** `update` subcommand: only report whether an update exists, don't install. */
|
|
26
|
+
checkOnly?: boolean;
|
|
25
27
|
}
|
|
26
28
|
export declare function parseArgs(argv: string[]): CliArgs;
|
|
27
29
|
export declare function printHelp(): void;
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,10 @@ export function parseArgs(argv) {
|
|
|
14
14
|
args.command = "serve";
|
|
15
15
|
startIndex = 1;
|
|
16
16
|
}
|
|
17
|
+
else if (argv[0] === "update" || argv[0] === "upgrade") {
|
|
18
|
+
args.command = "update";
|
|
19
|
+
startIndex = 1;
|
|
20
|
+
}
|
|
17
21
|
}
|
|
18
22
|
for (let i = startIndex; i < argv.length; i++) {
|
|
19
23
|
const arg = argv[i];
|
|
@@ -72,6 +76,9 @@ export function parseArgs(argv) {
|
|
|
72
76
|
case "--dry-run":
|
|
73
77
|
args.dryRun = true;
|
|
74
78
|
break;
|
|
79
|
+
case "--check":
|
|
80
|
+
args.checkOnly = true;
|
|
81
|
+
break;
|
|
75
82
|
default:
|
|
76
83
|
if (!arg.startsWith("-") && !args.prompt) {
|
|
77
84
|
args.prompt = arg;
|
|
@@ -85,6 +92,7 @@ export function printHelp() {
|
|
|
85
92
|
console.log(`
|
|
86
93
|
Usage:
|
|
87
94
|
bubble [options] [prompt] Start interactive TUI
|
|
95
|
+
bubble update [--check] Update to the latest version (alias: upgrade)
|
|
88
96
|
bubble serve --feishu [options] Run as a Feishu bot host
|
|
89
97
|
|
|
90
98
|
Options (default):
|
|
@@ -99,8 +107,12 @@ Options (default):
|
|
|
99
107
|
--dangerously-skip-permissions
|
|
100
108
|
Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
|
|
101
109
|
-p, --print Non-interactive mode (single prompt)
|
|
110
|
+
-v, --version Print the installed version and exit
|
|
102
111
|
-h, --help Show this help
|
|
103
112
|
|
|
113
|
+
Options (update):
|
|
114
|
+
--check Only report whether a newer version exists
|
|
115
|
+
|
|
104
116
|
Options (serve --feishu):
|
|
105
117
|
--setup Force the wizard (scan QR + bind first scope)
|
|
106
118
|
--kill-old Kill any conflicting bubble instance for the same App ID
|
package/dist/main.js
CHANGED
|
@@ -34,6 +34,16 @@ async function main() {
|
|
|
34
34
|
printHelp();
|
|
35
35
|
process.exit(0);
|
|
36
36
|
}
|
|
37
|
+
if (process.argv.includes("-v") || process.argv.includes("--version")) {
|
|
38
|
+
const { getCurrentVersion } = await import("./update/index.js");
|
|
39
|
+
console.log(`v${getCurrentVersion()}`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
if (args.command === "update") {
|
|
43
|
+
const { runUpdateCommand } = await import("./update/index.js");
|
|
44
|
+
const code = await runUpdateCommand({ checkOnly: args.checkOnly });
|
|
45
|
+
process.exit(code);
|
|
46
|
+
}
|
|
37
47
|
if (args.command === "serve") {
|
|
38
48
|
if (args.serveHost !== "feishu") {
|
|
39
49
|
console.error(chalk.red("Usage: bubble serve --feishu [--setup | --kill-old | --dry-run]"));
|
|
@@ -475,6 +485,8 @@ async function main() {
|
|
|
475
485
|
runMemorySummary,
|
|
476
486
|
runMemoryRefresh,
|
|
477
487
|
};
|
|
488
|
+
const { getStartupUpdateNotice } = await import("./update/index.js");
|
|
489
|
+
const updateNotice = await getStartupUpdateNotice();
|
|
478
490
|
const { runTui } = await import("./tui/run.js");
|
|
479
491
|
await runTui(agent, args, {
|
|
480
492
|
...commonOptions,
|
|
@@ -482,6 +494,7 @@ async function main() {
|
|
|
482
494
|
themeOverrides: themeConfig.overrides,
|
|
483
495
|
detectedTheme,
|
|
484
496
|
onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
|
|
497
|
+
updateNotice: updateNotice ?? undefined,
|
|
485
498
|
});
|
|
486
499
|
if (sessionManager) {
|
|
487
500
|
printOpenTuiExitSummary(sessionManager, {
|
package/dist/tui/run.d.ts
CHANGED
|
@@ -41,5 +41,7 @@ export interface RunTuiOptions {
|
|
|
41
41
|
runMemoryCompaction?: () => Promise<string>;
|
|
42
42
|
runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
|
|
43
43
|
runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
|
|
44
|
+
/** One-line "update available" notice shown on the home screen, if any. */
|
|
45
|
+
updateNotice?: string;
|
|
44
46
|
}
|
|
45
47
|
export declare function runTui(agent: Agent, args: CliArgs, options?: RunTuiOptions): Promise<void>;
|
package/dist/tui/run.js
CHANGED
|
@@ -13,6 +13,7 @@ import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceValue,
|
|
|
13
13
|
import { BUILTIN_PROVIDERS, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
|
|
14
14
|
import { calculateUsageCost } from "../model-pricing.js";
|
|
15
15
|
import { getAvailableThinkingLevels } from "../provider-transform.js";
|
|
16
|
+
import { getCurrentVersion } from "../update/index.js";
|
|
16
17
|
import { collectUsageStatsBundle, formatStatsPanelBody } from "../stats/usage.js";
|
|
17
18
|
import { parseSkillInvocation } from "../skills/invocation.js";
|
|
18
19
|
import { registry as slashRegistry } from "../slash-commands/index.js";
|
|
@@ -181,7 +182,7 @@ const PROMPT_SCANNER_IDLE_FRAMES = [" "];
|
|
|
181
182
|
const PROMPT_SCANNER_INTERVAL_MS = 80;
|
|
182
183
|
const SESSION_SIDEBAR_WIDTH = 42;
|
|
183
184
|
const SESSION_SIDEBAR_AUTO_WIDTH = 120;
|
|
184
|
-
const PROVIDER_DIALOG_ROWS =
|
|
185
|
+
const PROVIDER_DIALOG_ROWS = 13;
|
|
185
186
|
const QUESTION_MAX_TABS = 4;
|
|
186
187
|
const QUESTION_MAX_OPTIONS = 10;
|
|
187
188
|
const QUESTION_MAX_CONFIRM_ROWS = 3;
|
|
@@ -1104,7 +1105,7 @@ function OpenTuiApp(props) {
|
|
|
1104
1105
|
if (!safeSetText(ref, promptModeBadge()))
|
|
1105
1106
|
promptModeLabels.delete(ref);
|
|
1106
1107
|
};
|
|
1107
|
-
const promptModelTitle = () =>
|
|
1108
|
+
const promptModelTitle = () => displayModelWithThinking(props.agent.model, props.agent.thinking) || "no model";
|
|
1108
1109
|
const syncModelChrome = () => {
|
|
1109
1110
|
if (uiDisposed)
|
|
1110
1111
|
return;
|
|
@@ -3046,6 +3047,31 @@ function OpenTuiApp(props) {
|
|
|
3046
3047
|
// Keep the already-rendered local catalog when remote model discovery fails.
|
|
3047
3048
|
}
|
|
3048
3049
|
}
|
|
3050
|
+
function providerDialogMatchScore(item, query) {
|
|
3051
|
+
const label = (item.label || "").toLowerCase();
|
|
3052
|
+
const value = (item.value || "").toLowerCase();
|
|
3053
|
+
const haystack = [
|
|
3054
|
+
item.label,
|
|
3055
|
+
item.detail,
|
|
3056
|
+
item.value,
|
|
3057
|
+
item.category,
|
|
3058
|
+
item.footer,
|
|
3059
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
3060
|
+
if (label.startsWith(query))
|
|
3061
|
+
return 100;
|
|
3062
|
+
if (label.includes(query))
|
|
3063
|
+
return 80;
|
|
3064
|
+
if (value.includes(query))
|
|
3065
|
+
return 60;
|
|
3066
|
+
if (haystack.includes(query))
|
|
3067
|
+
return 40;
|
|
3068
|
+
// Fuzzy (subsequence) match is a last resort, and only against label+value
|
|
3069
|
+
// so long provider descriptions (e.g. "platform.moonshot.cn") don't produce
|
|
3070
|
+
// spurious hits like "gpt" matching "kimi-k2-thinking".
|
|
3071
|
+
if (fuzzyMatch(`${label} ${value}`, query))
|
|
3072
|
+
return 20;
|
|
3073
|
+
return 0;
|
|
3074
|
+
}
|
|
3049
3075
|
function providerDialogFilteredItems(state = providerDialog) {
|
|
3050
3076
|
if (!state || state.step === "key")
|
|
3051
3077
|
return [];
|
|
@@ -3053,16 +3079,12 @@ function OpenTuiApp(props) {
|
|
|
3053
3079
|
const query = state.query.trim().toLowerCase();
|
|
3054
3080
|
if (!query)
|
|
3055
3081
|
return items;
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
item.footer,
|
|
3063
|
-
].filter(Boolean).join(" ").toLowerCase();
|
|
3064
|
-
return haystack.includes(query) || fuzzyMatch(haystack, query);
|
|
3065
|
-
});
|
|
3082
|
+
const scored = items
|
|
3083
|
+
.map((item, order) => ({ item, order, score: providerDialogMatchScore(item, query) }))
|
|
3084
|
+
.filter((entry) => entry.score > 0);
|
|
3085
|
+
// Stable sort by score desc, preserving original catalog order within a tier.
|
|
3086
|
+
scored.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
3087
|
+
return scored.map((entry) => entry.item);
|
|
3066
3088
|
}
|
|
3067
3089
|
function providerDialogVisibleRows(state = providerDialog) {
|
|
3068
3090
|
if (!state)
|
|
@@ -3103,7 +3125,7 @@ function OpenTuiApp(props) {
|
|
|
3103
3125
|
providerDialogRoot.requestRender();
|
|
3104
3126
|
return;
|
|
3105
3127
|
}
|
|
3106
|
-
const width = Math.max(
|
|
3128
|
+
const width = Math.max(56, Math.min(76, dimensions().width - 4));
|
|
3107
3129
|
const height = PROVIDER_DIALOG_ROWS + 7;
|
|
3108
3130
|
providerDialogRoot.visible = true;
|
|
3109
3131
|
providerDialogRoot.width = dimensions().width;
|
|
@@ -5494,6 +5516,10 @@ function OpenTuiApp(props) {
|
|
|
5494
5516
|
paddingRight: 2,
|
|
5495
5517
|
}, [
|
|
5496
5518
|
h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...logoLines.map((line) => renderHomeLogoLine(line))),
|
|
5519
|
+
h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center", paddingTop: 1 }, h("text", { fg: theme.textMuted, content: `v${getCurrentVersion()}` })),
|
|
5520
|
+
...(props.options.updateNotice
|
|
5521
|
+
? [h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, h("text", { fg: theme.accent, content: props.options.updateNotice }))]
|
|
5522
|
+
: []),
|
|
5497
5523
|
h("box", { height: 1, minHeight: 0, flexShrink: 1 }),
|
|
5498
5524
|
h("box", {
|
|
5499
5525
|
ref: (ref) => {
|
|
@@ -5996,7 +6022,8 @@ function OpenTuiApp(props) {
|
|
|
5996
6022
|
width: "100%",
|
|
5997
6023
|
value: "",
|
|
5998
6024
|
placeholder: "",
|
|
5999
|
-
|
|
6025
|
+
textColor: theme.text,
|
|
6026
|
+
focusedTextColor: theme.text,
|
|
6000
6027
|
backgroundColor: theme.backgroundElement,
|
|
6001
6028
|
focusedBackgroundColor: theme.backgroundElement,
|
|
6002
6029
|
cursorColor: theme.primary,
|
|
@@ -6141,7 +6168,7 @@ function OpenTuiApp(props) {
|
|
|
6141
6168
|
},
|
|
6142
6169
|
visible: false,
|
|
6143
6170
|
position: "absolute",
|
|
6144
|
-
width:
|
|
6171
|
+
width: 76,
|
|
6145
6172
|
height: PROVIDER_DIALOG_ROWS + 7,
|
|
6146
6173
|
backgroundColor: theme.backgroundPanel,
|
|
6147
6174
|
flexDirection: "column",
|
|
@@ -6178,7 +6205,8 @@ function OpenTuiApp(props) {
|
|
|
6178
6205
|
width: "100%",
|
|
6179
6206
|
value: "",
|
|
6180
6207
|
placeholder: "Search",
|
|
6181
|
-
|
|
6208
|
+
textColor: theme.text,
|
|
6209
|
+
focusedTextColor: theme.text,
|
|
6182
6210
|
backgroundColor: theme.backgroundPanel,
|
|
6183
6211
|
focusedBackgroundColor: theme.backgroundPanel,
|
|
6184
6212
|
cursorColor: theme.primary,
|
|
@@ -6191,15 +6219,11 @@ function OpenTuiApp(props) {
|
|
|
6191
6219
|
providerDialog = { ...state, apiKey: value, error: undefined };
|
|
6192
6220
|
}
|
|
6193
6221
|
else {
|
|
6222
|
+
const query = value.trim().toLowerCase();
|
|
6194
6223
|
const items = providerDialogItemsFor(state.step, state.providerId).filter((item) => {
|
|
6195
|
-
const query = value.trim().toLowerCase();
|
|
6196
6224
|
if (!query)
|
|
6197
6225
|
return true;
|
|
6198
|
-
|
|
6199
|
-
.filter(Boolean)
|
|
6200
|
-
.join(" ")
|
|
6201
|
-
.toLowerCase();
|
|
6202
|
-
return haystack.includes(query) || fuzzyMatch(haystack, query);
|
|
6226
|
+
return providerDialogMatchScore(item, query) > 0;
|
|
6203
6227
|
});
|
|
6204
6228
|
providerDialog = {
|
|
6205
6229
|
...state,
|
|
@@ -7054,7 +7078,7 @@ function renderMarkdownContent(content, syntaxStyle, options) {
|
|
|
7054
7078
|
bg: theme.background,
|
|
7055
7079
|
width: "100%",
|
|
7056
7080
|
tableOptions: {
|
|
7057
|
-
widthMode: "
|
|
7081
|
+
widthMode: "content",
|
|
7058
7082
|
columnFitter: "balanced",
|
|
7059
7083
|
wrapMode: "word",
|
|
7060
7084
|
cellPadding: 1,
|
|
@@ -7274,7 +7298,13 @@ function syncMarkdownRenderable(markdown, content, streaming) {
|
|
|
7274
7298
|
return;
|
|
7275
7299
|
markdown.content = content;
|
|
7276
7300
|
markdown.streaming = streaming;
|
|
7277
|
-
markdown
|
|
7301
|
+
// While streaming, let OpenTUI's incremental markdown/code-block rendering do
|
|
7302
|
+
// its job — clearing the parse cache every delta forces the (syntax-
|
|
7303
|
+
// highlighted) code blocks to be rebuilt and re-highlighted on every token,
|
|
7304
|
+
// which is the source of the visible flicker on streamed code blocks. Clear
|
|
7305
|
+
// the cache only once streaming ends, to fully reparse the finalized content.
|
|
7306
|
+
if (!streaming)
|
|
7307
|
+
markdown.clearCache();
|
|
7278
7308
|
}
|
|
7279
7309
|
function updateAssistantPartEntries(entry, parts, options, streaming) {
|
|
7280
7310
|
const partsBox = entry.refs.partsBox;
|
|
@@ -7613,7 +7643,7 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
|
|
|
7613
7643
|
width: "100%",
|
|
7614
7644
|
flexShrink: 0,
|
|
7615
7645
|
tableOptions: {
|
|
7616
|
-
widthMode: "
|
|
7646
|
+
widthMode: "content",
|
|
7617
7647
|
columnFitter: "balanced",
|
|
7618
7648
|
wrapMode: "word",
|
|
7619
7649
|
cellPadding: 1,
|
|
@@ -8293,7 +8323,7 @@ function renderTool(tool, syntaxStyle, width = 80) {
|
|
|
8293
8323
|
const color = toolColor(tool);
|
|
8294
8324
|
const diff = extractToolDiff(tool);
|
|
8295
8325
|
if (diff && !tool.resultCollapsed && !tool.isError && (tool.name === "edit" || tool.name === "apply_patch")) {
|
|
8296
|
-
return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
|
|
8326
|
+
return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0, backgroundColor: theme.diffContextBg }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
|
|
8297
8327
|
}
|
|
8298
8328
|
if (!tool.resultCollapsed && isWritePreviewTool(tool)) {
|
|
8299
8329
|
const hasContent = typeof tool.args.content === "string";
|
|
@@ -8401,10 +8431,18 @@ function pickerTitle(kind, providerId) {
|
|
|
8401
8431
|
}
|
|
8402
8432
|
}
|
|
8403
8433
|
function getModelPickerReasoningLevels(providerId, modelId) {
|
|
8404
|
-
|
|
8434
|
+
// Only expand into one picker row per effort for models that genuinely have a
|
|
8435
|
+
// reasoning-effort spectrum: OpenAI's reasoning models (codex gpt-5.x:
|
|
8436
|
+
// off/minimal/low/medium/high/xhigh) and DeepSeek's v4 models. Other providers
|
|
8437
|
+
// (e.g. GLM, Moonshot/Kimi) only have a thinking on/off toggle, not an effort
|
|
8438
|
+
// control, so they stay as a single row.
|
|
8439
|
+
const isOpenAIReasoning = providerId === "openai" || providerId === "openai-codex";
|
|
8440
|
+
const isDeepseekReasoning = providerId === "deepseek" && (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro");
|
|
8441
|
+
if (!isOpenAIReasoning && !isDeepseekReasoning)
|
|
8405
8442
|
return [];
|
|
8406
|
-
|
|
8407
|
-
|
|
8443
|
+
const levels = getAvailableThinkingLevels(providerId, modelId);
|
|
8444
|
+
// gpt-4o and friends report only ["off"] — keep those as a single row too.
|
|
8445
|
+
return levels.length > 1 ? levels : [];
|
|
8408
8446
|
}
|
|
8409
8447
|
function displayModelWithThinking(model, thinkingLevel) {
|
|
8410
8448
|
if (!model)
|
|
@@ -8412,7 +8450,10 @@ function displayModelWithThinking(model, thinkingLevel) {
|
|
|
8412
8450
|
const { providerId, modelId } = decodeModel(model);
|
|
8413
8451
|
if (!providerId)
|
|
8414
8452
|
return displayModel(model);
|
|
8415
|
-
|
|
8453
|
+
// Use the same scoping as the picker: only models with a real reasoning-effort
|
|
8454
|
+
// spectrum (OpenAI codex gpt-5.x, deepseek v4) get the "(level)" suffix. The
|
|
8455
|
+
// on/off thinking toggle on GLM / Moonshot(Kimi) is not an effort control.
|
|
8456
|
+
const levels = getModelPickerReasoningLevels(providerId, modelId);
|
|
8416
8457
|
if (levels.length > 1 && thinkingLevel !== "off") {
|
|
8417
8458
|
return `${displayModel(model)} (${thinkingLevel})`;
|
|
8418
8459
|
}
|
|
@@ -9066,11 +9107,18 @@ function toolPath(tool) {
|
|
|
9066
9107
|
?? (Array.isArray(tool.metadata?.paths) ? tool.metadata.paths[0] : undefined);
|
|
9067
9108
|
return typeof value === "string" ? value : undefined;
|
|
9068
9109
|
}
|
|
9110
|
+
// Strip only leading/trailing newlines — NOT a full .trim(). A blank context
|
|
9111
|
+
// line in a unified diff is a single space (" "); plain .trim() would delete a
|
|
9112
|
+
// trailing blank context line, leaving the hunk body shorter than its @@ header
|
|
9113
|
+
// count and breaking the diff parser ("Added line count did not match").
|
|
9114
|
+
function stripDiffEdgeNewlines(diff) {
|
|
9115
|
+
return diff.replace(/^\n+/, "").replace(/\n+$/, "");
|
|
9116
|
+
}
|
|
9069
9117
|
function extractToolDiff(tool) {
|
|
9070
9118
|
if (tool.resultCollapsed)
|
|
9071
9119
|
return undefined;
|
|
9072
9120
|
if (typeof tool.metadata?.diff === "string" && tool.metadata.diff.trim().length > 0) {
|
|
9073
|
-
return tool.metadata.diff
|
|
9121
|
+
return stripDiffEdgeNewlines(tool.metadata.diff);
|
|
9074
9122
|
}
|
|
9075
9123
|
if (!tool.result)
|
|
9076
9124
|
return undefined;
|
|
@@ -9086,10 +9134,14 @@ function extractToolDiff(tool) {
|
|
|
9086
9134
|
const rawDiff = tool.result.slice(index + marker.length);
|
|
9087
9135
|
const diagnosticsIndex = rawDiff.search(/\n\nLSP diagnostics in /);
|
|
9088
9136
|
const diff = diagnosticsIndex === -1 ? rawDiff : rawDiff.slice(0, diagnosticsIndex);
|
|
9089
|
-
return diff.trim().length > 0 ? diff : undefined;
|
|
9090
|
-
}
|
|
9091
|
-
function diffViewMode(
|
|
9092
|
-
|
|
9137
|
+
return diff.trim().length > 0 ? stripDiffEdgeNewlines(diff) : undefined;
|
|
9138
|
+
}
|
|
9139
|
+
function diffViewMode(_width = 80) {
|
|
9140
|
+
// Always unified: split view pads the shorter side with empty filler rows that
|
|
9141
|
+
// OpenTUI's DiffRenderable leaves uncolored, which shows up as bright white
|
|
9142
|
+
// blocks in light mode. Unified view has no filler rows — every line is
|
|
9143
|
+
// add/remove/context and gets a background — so the edit area stays uniform.
|
|
9144
|
+
return "unified";
|
|
9093
9145
|
}
|
|
9094
9146
|
function filetype(filePath) {
|
|
9095
9147
|
if (!filePath)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update: `bubble update` / `bubble upgrade`, plus a cached startup
|
|
3
|
+
* "update available" check.
|
|
4
|
+
*
|
|
5
|
+
* Bubble ships as the npm package `@bubblebrain-ai/bubble`. Updating just means
|
|
6
|
+
* re-installing it globally with whatever package manager put it there, so we
|
|
7
|
+
* detect the install method and run the matching command.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PACKAGE_NAME = "@bubblebrain-ai/bubble";
|
|
10
|
+
export declare function getCurrentVersion(): string;
|
|
11
|
+
export declare function fetchLatestVersion(timeoutMs?: number): Promise<string | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Compare two semver-ish strings. Returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
14
|
+
* Handles `x.y.z` and a single pre-release tag (`x.y.z-beta.1`); a release
|
|
15
|
+
* always outranks a pre-release of the same numeric version.
|
|
16
|
+
*/
|
|
17
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
18
|
+
export type PackageManager = "npm" | "bun" | "pnpm" | "yarn" | "homebrew" | "unknown";
|
|
19
|
+
export interface InstallInfo {
|
|
20
|
+
manager: PackageManager;
|
|
21
|
+
isGlobal: boolean;
|
|
22
|
+
isLocalCheckout: boolean;
|
|
23
|
+
installPath: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Figure out how this copy of Bubble was installed by inspecting the real path
|
|
27
|
+
* of the package directory (two levels up from this module: dist/update -> pkg).
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectInstall(): InstallInfo;
|
|
30
|
+
export declare function upgradeCommandFor(manager: PackageManager): {
|
|
31
|
+
cmd: string;
|
|
32
|
+
args: string[];
|
|
33
|
+
} | null;
|
|
34
|
+
/**
|
|
35
|
+
* `bubble update` entry point. Returns a process exit code.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runUpdateCommand(opts?: {
|
|
38
|
+
checkOnly?: boolean;
|
|
39
|
+
}): Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* Returns a one-line "update available" notice if the cached latest version is
|
|
42
|
+
* newer than the running one. Reads only a local cache file (fast, no network
|
|
43
|
+
* on the hot path); a stale cache triggers a fire-and-forget refresh so the
|
|
44
|
+
* next launch is accurate. Never throws.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getStartupUpdateNotice(): Promise<string | null>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update: `bubble update` / `bubble upgrade`, plus a cached startup
|
|
3
|
+
* "update available" check.
|
|
4
|
+
*
|
|
5
|
+
* Bubble ships as the npm package `@bubblebrain-ai/bubble`. Updating just means
|
|
6
|
+
* re-installing it globally with whatever package manager put it there, so we
|
|
7
|
+
* detect the install method and run the matching command.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { realpathSync } from "node:fs";
|
|
13
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { getBubbleHome } from "../bubble-home.js";
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
export const PACKAGE_NAME = "@bubblebrain-ai/bubble";
|
|
18
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
19
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // once a day
|
|
20
|
+
export function getCurrentVersion() {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = require("../../package.json");
|
|
23
|
+
return pkg.version ?? "0.0.0";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "0.0.0";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function fetchLatestVersion(timeoutMs = 5000) {
|
|
30
|
+
try {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(REGISTRY_URL, {
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
headers: { accept: "application/json" },
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
return null;
|
|
40
|
+
const data = (await res.json());
|
|
41
|
+
return data.version ?? null;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Compare two semver-ish strings. Returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
53
|
+
* Handles `x.y.z` and a single pre-release tag (`x.y.z-beta.1`); a release
|
|
54
|
+
* always outranks a pre-release of the same numeric version.
|
|
55
|
+
*/
|
|
56
|
+
export function compareVersions(a, b) {
|
|
57
|
+
const parse = (v) => {
|
|
58
|
+
const cleaned = v.trim().replace(/^v/, "");
|
|
59
|
+
const [core, pre] = cleaned.split("-", 2);
|
|
60
|
+
const nums = core.split(".").map((n) => parseInt(n, 10) || 0);
|
|
61
|
+
while (nums.length < 3)
|
|
62
|
+
nums.push(0);
|
|
63
|
+
return { nums, pre: pre ?? "" };
|
|
64
|
+
};
|
|
65
|
+
const pa = parse(a);
|
|
66
|
+
const pb = parse(b);
|
|
67
|
+
for (let i = 0; i < 3; i++) {
|
|
68
|
+
if (pa.nums[i] > pb.nums[i])
|
|
69
|
+
return 1;
|
|
70
|
+
if (pa.nums[i] < pb.nums[i])
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
// Equal numeric core: no pre-release beats a pre-release.
|
|
74
|
+
if (pa.pre === pb.pre)
|
|
75
|
+
return 0;
|
|
76
|
+
if (!pa.pre)
|
|
77
|
+
return 1;
|
|
78
|
+
if (!pb.pre)
|
|
79
|
+
return -1;
|
|
80
|
+
return pa.pre > pb.pre ? 1 : -1;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Figure out how this copy of Bubble was installed by inspecting the real path
|
|
84
|
+
* of the package directory (two levels up from this module: dist/update -> pkg).
|
|
85
|
+
*/
|
|
86
|
+
export function detectInstall() {
|
|
87
|
+
const rawRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
88
|
+
let installPath = rawRoot;
|
|
89
|
+
try {
|
|
90
|
+
installPath = realpathSync(rawRoot);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// keep rawRoot
|
|
94
|
+
}
|
|
95
|
+
const lower = installPath.replace(/\\/g, "/").toLowerCase();
|
|
96
|
+
const isUnderNodeModules = lower.includes("/node_modules/");
|
|
97
|
+
// A dev/source checkout has src/ alongside dist/ and isn't under node_modules.
|
|
98
|
+
const isLocalCheckout = !isUnderNodeModules;
|
|
99
|
+
let manager = "unknown";
|
|
100
|
+
if (lower.includes("/cellar/") || lower.includes("/homebrew/")) {
|
|
101
|
+
manager = "homebrew";
|
|
102
|
+
}
|
|
103
|
+
else if (lower.includes("/.bun/") || lower.includes("/bun/install/")) {
|
|
104
|
+
manager = "bun";
|
|
105
|
+
}
|
|
106
|
+
else if (lower.includes("/pnpm/") || lower.includes("/.pnpm/")) {
|
|
107
|
+
manager = "pnpm";
|
|
108
|
+
}
|
|
109
|
+
else if (lower.includes("/.yarn/") || lower.includes("/yarn/global")) {
|
|
110
|
+
manager = "yarn";
|
|
111
|
+
}
|
|
112
|
+
else if (isUnderNodeModules) {
|
|
113
|
+
manager = "npm";
|
|
114
|
+
}
|
|
115
|
+
return { manager, isGlobal: isUnderNodeModules, isLocalCheckout, installPath };
|
|
116
|
+
}
|
|
117
|
+
export function upgradeCommandFor(manager) {
|
|
118
|
+
const spec = `${PACKAGE_NAME}@latest`;
|
|
119
|
+
switch (manager) {
|
|
120
|
+
case "npm":
|
|
121
|
+
case "unknown": // default to npm
|
|
122
|
+
return { cmd: "npm", args: ["install", "-g", spec] };
|
|
123
|
+
case "bun":
|
|
124
|
+
return { cmd: "bun", args: ["add", "-g", spec] };
|
|
125
|
+
case "pnpm":
|
|
126
|
+
return { cmd: "pnpm", args: ["add", "-g", spec] };
|
|
127
|
+
case "yarn":
|
|
128
|
+
return { cmd: "yarn", args: ["global", "add", spec] };
|
|
129
|
+
case "homebrew":
|
|
130
|
+
return null; // handled separately (brew upgrade)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function spawnInherit(cmd, args) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const child = spawn(cmd, args, { stdio: "inherit", env: process.env });
|
|
136
|
+
child.on("error", () => resolve(127));
|
|
137
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* `bubble update` entry point. Returns a process exit code.
|
|
142
|
+
*/
|
|
143
|
+
export async function runUpdateCommand(opts = {}) {
|
|
144
|
+
const current = getCurrentVersion();
|
|
145
|
+
process.stdout.write(`Bubble v${current}\n`);
|
|
146
|
+
process.stdout.write("Checking npm for the latest version…\n");
|
|
147
|
+
const latest = await fetchLatestVersion();
|
|
148
|
+
if (!latest) {
|
|
149
|
+
process.stderr.write("Could not reach the npm registry. Check your connection and try again.\n");
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
if (compareVersions(latest, current) <= 0) {
|
|
153
|
+
process.stdout.write(`You're already on the latest version (v${current}).\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(`Update available: v${current} → v${latest}\n`);
|
|
157
|
+
if (opts.checkOnly) {
|
|
158
|
+
process.stdout.write("Run `bubble update` to install it.\n");
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
const info = detectInstall();
|
|
162
|
+
if (info.isLocalCheckout) {
|
|
163
|
+
process.stderr.write("This looks like a local/development checkout, not a global install.\n" +
|
|
164
|
+
"Update it with:\n git pull && npm run build\n");
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
if (info.manager === "homebrew") {
|
|
168
|
+
process.stderr.write("Bubble was installed via Homebrew. Update it with:\n brew upgrade bubble\n");
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const command = upgradeCommandFor(info.manager);
|
|
172
|
+
if (!command) {
|
|
173
|
+
process.stderr.write(`Couldn't determine how to update automatically. Run:\n npm install -g ${PACKAGE_NAME}@latest\n`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write(`\nUpdating via ${command.cmd}…\n\n`);
|
|
177
|
+
const code = await spawnInherit(command.cmd, command.args);
|
|
178
|
+
if (code === 0) {
|
|
179
|
+
process.stdout.write(`\n✓ Updated to v${latest}. Restart bubble to use the new version.\n`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
process.stderr.write(`\nUpdate failed (exit ${code}). Try running it manually:\n ${command.cmd} ${command.args.join(" ")}\n` +
|
|
183
|
+
"If this is a permissions error, you may need elevated privileges or to fix your global install prefix.\n");
|
|
184
|
+
return code;
|
|
185
|
+
}
|
|
186
|
+
function cacheFile() {
|
|
187
|
+
return join(getBubbleHome(), "update-check.json");
|
|
188
|
+
}
|
|
189
|
+
async function readCache() {
|
|
190
|
+
try {
|
|
191
|
+
const raw = await readFile(cacheFile(), "utf8");
|
|
192
|
+
const data = JSON.parse(raw);
|
|
193
|
+
if (typeof data.lastCheck === "number" && typeof data.latest === "string") {
|
|
194
|
+
return { lastCheck: data.lastCheck, latest: data.latest };
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function writeCache(cache) {
|
|
203
|
+
try {
|
|
204
|
+
const file = cacheFile();
|
|
205
|
+
await mkdir(dirname(file), { recursive: true });
|
|
206
|
+
await writeFile(file, JSON.stringify(cache), "utf8");
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// best-effort; never fail startup over a cache write
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function refreshCacheInBackground(now) {
|
|
213
|
+
const latest = await fetchLatestVersion(4000);
|
|
214
|
+
if (latest) {
|
|
215
|
+
await writeCache({ lastCheck: now, latest });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Returns a one-line "update available" notice if the cached latest version is
|
|
220
|
+
* newer than the running one. Reads only a local cache file (fast, no network
|
|
221
|
+
* on the hot path); a stale cache triggers a fire-and-forget refresh so the
|
|
222
|
+
* next launch is accurate. Never throws.
|
|
223
|
+
*/
|
|
224
|
+
export async function getStartupUpdateNotice() {
|
|
225
|
+
try {
|
|
226
|
+
const current = getCurrentVersion();
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
const cache = await readCache();
|
|
229
|
+
if (!cache || now - cache.lastCheck > CHECK_INTERVAL_MS) {
|
|
230
|
+
void refreshCacheInBackground(now);
|
|
231
|
+
}
|
|
232
|
+
if (cache && compareVersions(cache.latest, current) > 0) {
|
|
233
|
+
return `Update available: v${current} → v${cache.latest} · run \`bubble update\``;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|