@bubblebrain-ai/bubble 0.0.47 → 0.0.49
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/README.md +17 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +11 -3
- package/dist/model-catalog.d.ts +13 -0
- package/dist/model-catalog.js +44 -2
- package/dist/provider-grok.d.ts +24 -0
- package/dist/provider-grok.js +47 -0
- package/dist/provider-registry.d.ts +10 -1
- package/dist/provider-registry.js +140 -4
- package/dist/provider.js +11 -0
- package/dist/session-log.js +1 -0
- package/dist/tui-ink/app.js +72 -14
- package/dist/tui-ink/display-history.d.ts +2 -0
- package/dist/tui-ink/footer.d.ts +14 -3
- package/dist/tui-ink/footer.js +19 -5
- package/dist/tui-ink/input-box.js +18 -6
- package/dist/tui-ink/wordmark.d.ts +8 -0
- package/dist/tui-ink/wordmark.js +103 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,23 @@ The `npm install` step puts a small Node.js launcher named `bubble` on your PATH
|
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
|
+
One-line install (macOS / Linux):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
curl -fsSL https://raw.githubusercontent.com/DylanDDeng/bubble/main/install.sh | sh
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Windows (PowerShell):
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
powershell -c "irm https://raw.githubusercontent.com/DylanDDeng/bubble/main/install.ps1 | iex"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The installer checks for Node.js 20+ and Bun (installing Bun for you if it is
|
|
37
|
+
missing), then installs the `bubble` CLI globally via npm.
|
|
38
|
+
|
|
39
|
+
Or install directly via npm:
|
|
40
|
+
|
|
24
41
|
```bash
|
|
25
42
|
npm install -g @bubblebrain-ai/bubble
|
|
26
43
|
```
|
package/dist/agent.d.ts
CHANGED
|
@@ -214,7 +214,7 @@ export declare class Agent {
|
|
|
214
214
|
unlockDeferredTools(names: string[]): void;
|
|
215
215
|
/** All deferred tools in this session (for tool_search to inspect). */
|
|
216
216
|
listDeferredTools(): ToolRegistryEntry[];
|
|
217
|
-
getSystemPromptToolOptions(): Pick<import("./system-prompt.js").SystemPromptOptions, "tools" | "toolSnippets" | "guidelines" | "modelRoutingPrompt">;
|
|
217
|
+
getSystemPromptToolOptions(): Pick<import("./system-prompt.js").SystemPromptOptions, "tools" | "toolSnippets" | "guidelines" | "modelRoutingPrompt" | "memoryPrompt">;
|
|
218
218
|
/** Current routing menu (design §4); undefined when no accessor is wired. */
|
|
219
219
|
buildModelRoutingPromptSection(): string | undefined;
|
|
220
220
|
/**
|
package/dist/agent.js
CHANGED
|
@@ -345,6 +345,9 @@ export class Agent {
|
|
|
345
345
|
// Rendered through the live accessor (design §1.5), so every host-
|
|
346
346
|
// triggered prompt rebuild picks up the current catalog.
|
|
347
347
|
modelRoutingPrompt: this.buildModelRoutingPromptSection(),
|
|
348
|
+
// Model switches rebuild the prompt from these options; without this the
|
|
349
|
+
// persistent memory (buildMemoryPrompt) is silently dropped on switch.
|
|
350
|
+
memoryPrompt: this.memoryPrompt,
|
|
348
351
|
};
|
|
349
352
|
}
|
|
350
353
|
/** Current routing menu (design §4); undefined when no accessor is wired. */
|
|
@@ -705,6 +708,7 @@ export class Agent {
|
|
|
705
708
|
const textSanitizer = createStreamingInternalReminderSanitizer();
|
|
706
709
|
const reasoningSanitizer = createStreamingInternalReminderSanitizer();
|
|
707
710
|
let turnUsage;
|
|
711
|
+
let turnSystemFingerprint;
|
|
708
712
|
let assistantAppended = false;
|
|
709
713
|
currentAssistantMsg = assistantMsg;
|
|
710
714
|
currentAssistantAppended = false;
|
|
@@ -898,6 +902,10 @@ export class Agent {
|
|
|
898
902
|
}
|
|
899
903
|
}
|
|
900
904
|
break;
|
|
905
|
+
case "response_metadata":
|
|
906
|
+
turnSystemFingerprint = chunk.systemFingerprint;
|
|
907
|
+
assistantMsg.systemFingerprint = chunk.systemFingerprint;
|
|
908
|
+
break;
|
|
901
909
|
}
|
|
902
910
|
for (const update of this.subagents.drainToolUpdates())
|
|
903
911
|
yield emit(update);
|
|
@@ -938,7 +946,7 @@ export class Agent {
|
|
|
938
946
|
if (consecutiveEmptyAssistantRecoveries < MAX_EMPTY_ASSISTANT_RECOVERIES) {
|
|
939
947
|
consecutiveEmptyAssistantRecoveries += 1;
|
|
940
948
|
this.injectSystemReminder(EMPTY_ASSISTANT_RECOVERY_REMINDER);
|
|
941
|
-
yield emit({ type: "turn_end", usage: turnUsage, willContinue: true });
|
|
949
|
+
yield emit({ type: "turn_end", usage: turnUsage, systemFingerprint: turnSystemFingerprint, willContinue: true });
|
|
942
950
|
continue;
|
|
943
951
|
}
|
|
944
952
|
assistantMsg.content = EMPTY_ASSISTANT_FALLBACK;
|
|
@@ -1328,7 +1336,7 @@ export class Agent {
|
|
|
1328
1336
|
},
|
|
1329
1337
|
});
|
|
1330
1338
|
flushQueuedReminders();
|
|
1331
|
-
yield emit({ type: "turn_end", usage: turnUsage, willContinue: true });
|
|
1339
|
+
yield emit({ type: "turn_end", usage: turnUsage, systemFingerprint: turnSystemFingerprint, willContinue: true });
|
|
1332
1340
|
// Auto-continue: if we have tool results, the LLM needs to respond to them.
|
|
1333
1341
|
// Emitting the turn boundary keeps UI renderers aligned with the persisted
|
|
1334
1342
|
// assistant/tool message sequence instead of merging the next answer into
|
|
@@ -1360,7 +1368,7 @@ export class Agent {
|
|
|
1360
1368
|
for (const event of stopHook.events)
|
|
1361
1369
|
yield emit(event);
|
|
1362
1370
|
const willContinue = !!hookState.forceContinuationReason;
|
|
1363
|
-
yield emit({ type: "turn_end", usage: turnUsage, willContinue });
|
|
1371
|
+
yield emit({ type: "turn_end", usage: turnUsage, systemFingerprint: turnSystemFingerprint, willContinue });
|
|
1364
1372
|
if (willContinue) {
|
|
1365
1373
|
delete hookState.forceContinuationReason;
|
|
1366
1374
|
continue;
|
package/dist/model-catalog.d.ts
CHANGED
|
@@ -48,5 +48,18 @@ export declare function listDynamicModelMetadata(providerId: string): BuiltinMod
|
|
|
48
48
|
export declare function getBuiltinModel(providerId: string, modelId: string): BuiltinModelDefinition | undefined;
|
|
49
49
|
export declare function getModelDefaultReasoningLevel(providerId: string, modelId: string): ReasoningEffort | undefined;
|
|
50
50
|
export declare function getBuiltinProvider(providerId: string): BuiltinProviderDefinition | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Infer capability metadata for a Grok model id the /models endpoint returned
|
|
53
|
+
* without metadata. Flagship reasoning models (grok-N.M) expose low/medium/high
|
|
54
|
+
* with thinking always on and a 500K context window — matching the curated
|
|
55
|
+
* grok-4.5 entry — while composer/fast/mini/flash variants have no effort
|
|
56
|
+
* control and a 200K window. Pure (id-only) so it works even before remote
|
|
57
|
+
* model discovery runs.
|
|
58
|
+
*/
|
|
59
|
+
export declare function inferGrokModelMetadata(modelId: string): {
|
|
60
|
+
levels: ReasoningEffort[];
|
|
61
|
+
defaultLevel?: ReasoningEffort;
|
|
62
|
+
contextWindow?: number;
|
|
63
|
+
};
|
|
51
64
|
export declare function getModelContextWindow(providerId: string, modelId: string): number | undefined;
|
|
52
65
|
export declare function getToolOutputTokenLimit(providerId: string, modelId: string): number | undefined;
|
package/dist/model-catalog.js
CHANGED
|
@@ -239,10 +239,28 @@ export function getBuiltinModel(providerId, modelId) {
|
|
|
239
239
|
|| (providerId === "openai" ? dynamicOverlay.get(overlayKey("openai-codex", modelId)) : undefined);
|
|
240
240
|
if (overlayHit)
|
|
241
241
|
return overlayHit;
|
|
242
|
-
|
|
242
|
+
const staticHit = BUILTIN_MODELS.find((model) => model.providerId === providerId && model.id === modelId)
|
|
243
243
|
|| (providerId === "openai"
|
|
244
244
|
? BUILTIN_MODELS.find((model) => model.providerId === "openai-codex" && model.id === modelId)
|
|
245
245
|
: undefined);
|
|
246
|
+
if (staticHit)
|
|
247
|
+
return staticHit;
|
|
248
|
+
// Dynamically-discovered grok models aren't in the static catalog until
|
|
249
|
+
// remote discovery runs. Synthesize their metadata from the id so reasoning
|
|
250
|
+
// levels, default effort, and context window resolve correctly on a fresh
|
|
251
|
+
// start or resumed session (before the first /model discovery).
|
|
252
|
+
if (providerId === "grok") {
|
|
253
|
+
const inferred = inferGrokModelMetadata(modelId);
|
|
254
|
+
return {
|
|
255
|
+
id: modelId,
|
|
256
|
+
name: modelId,
|
|
257
|
+
providerId: "grok",
|
|
258
|
+
reasoningLevels: inferred.levels,
|
|
259
|
+
defaultReasoningLevel: inferred.defaultLevel,
|
|
260
|
+
contextWindow: inferred.contextWindow,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
246
264
|
}
|
|
247
265
|
export function getModelDefaultReasoningLevel(providerId, modelId) {
|
|
248
266
|
return getBuiltinModel(providerId, modelId)?.defaultReasoningLevel;
|
|
@@ -250,8 +268,32 @@ export function getModelDefaultReasoningLevel(providerId, modelId) {
|
|
|
250
268
|
export function getBuiltinProvider(providerId) {
|
|
251
269
|
return BUILTIN_PROVIDERS.find((provider) => provider.id === providerId);
|
|
252
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Infer capability metadata for a Grok model id the /models endpoint returned
|
|
273
|
+
* without metadata. Flagship reasoning models (grok-N.M) expose low/medium/high
|
|
274
|
+
* with thinking always on and a 500K context window — matching the curated
|
|
275
|
+
* grok-4.5 entry — while composer/fast/mini/flash variants have no effort
|
|
276
|
+
* control and a 200K window. Pure (id-only) so it works even before remote
|
|
277
|
+
* model discovery runs.
|
|
278
|
+
*/
|
|
279
|
+
export function inferGrokModelMetadata(modelId) {
|
|
280
|
+
const id = modelId.toLowerCase();
|
|
281
|
+
if (/(composer|fast|mini|flash)/.test(id)) {
|
|
282
|
+
return { levels: ["off"], contextWindow: 200000 };
|
|
283
|
+
}
|
|
284
|
+
return { levels: ["low", "medium", "high"], defaultLevel: "high", contextWindow: 500000 };
|
|
285
|
+
}
|
|
253
286
|
export function getModelContextWindow(providerId, modelId) {
|
|
254
|
-
|
|
287
|
+
const builtin = getBuiltinModel(providerId, modelId);
|
|
288
|
+
if (builtin?.contextWindow !== undefined)
|
|
289
|
+
return builtin.contextWindow;
|
|
290
|
+
// Dynamically-discovered grok models aren't in the static catalog until
|
|
291
|
+
// discovery runs; infer the window from the id so context usage can render a
|
|
292
|
+
// percentage even before discovery (e.g. on a resumed session right after
|
|
293
|
+
// startup).
|
|
294
|
+
if (providerId === "grok")
|
|
295
|
+
return inferGrokModelMetadata(modelId).contextWindow;
|
|
296
|
+
return undefined;
|
|
255
297
|
}
|
|
256
298
|
export function getToolOutputTokenLimit(providerId, modelId) {
|
|
257
299
|
return getBuiltinModel(providerId, modelId)?.toolOutputTokenLimit;
|
package/dist/provider-grok.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { OAuthCredentials } from "./oauth/types.js";
|
|
11
11
|
import { type ChatGptFetch } from "./network/chatgpt-transport.js";
|
|
12
|
+
import { type ThinkingLevel } from "./types.js";
|
|
12
13
|
export declare const GROK_SUBSCRIPTION_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
13
14
|
export declare const GROK_CLIENT_VERSION = "0.2.93";
|
|
14
15
|
export declare function isGrokSubscriptionBaseUrl(baseURL: string): boolean;
|
|
@@ -24,3 +25,26 @@ export interface GrokAuthAdapter {
|
|
|
24
25
|
* proactively refreshes) the stored OAuth credentials per request instead.
|
|
25
26
|
*/
|
|
26
27
|
export declare function createGrokSubscriptionFetch(auth: GrokAuthAdapter, baseFetch?: ChatGptFetch): ChatGptFetch;
|
|
28
|
+
export interface GrokModelDescriptor {
|
|
29
|
+
id: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
/** Server-declared context window (the /models `context_window` field). */
|
|
32
|
+
contextWindow?: number;
|
|
33
|
+
/** Server-declared reasoning effort ladder, sorted ascending. */
|
|
34
|
+
reasoningEfforts?: ThinkingLevel[];
|
|
35
|
+
/** Server-declared default effort (the /models `reasoning_effort` field). */
|
|
36
|
+
defaultReasoningEffort?: ThinkingLevel;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the model list for a Grok subscription account. The CLI chat proxy
|
|
40
|
+
* gates on the grok-cli identity headers plus a fresh OAuth bearer, so this
|
|
41
|
+
* rides the same refreshing fetch used for chat requests instead of a raw
|
|
42
|
+
* global fetch. The /models payload carries richer metadata than id+name —
|
|
43
|
+
* `context_window`, `reasoning_efforts`, and the default `reasoning_effort` —
|
|
44
|
+
* which this surfaces so discovery can stop inferring them. Throws on a
|
|
45
|
+
* non-2xx so callers fall back to the curated catalog.
|
|
46
|
+
*/
|
|
47
|
+
export declare function fetchGrokSubscriptionModels(baseURL: string, auth: GrokAuthAdapter, options?: {
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
fetch?: ChatGptFetch;
|
|
50
|
+
}): Promise<GrokModelDescriptor[]>;
|
package/dist/provider-grok.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* OAuth bearer fresh across long sessions.
|
|
9
9
|
*/
|
|
10
10
|
import { getChatGptFetch } from "./network/chatgpt-transport.js";
|
|
11
|
+
import { THINKING_LEVELS } from "./types.js";
|
|
11
12
|
export const GROK_SUBSCRIPTION_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
12
13
|
// The proxy rejects requests below a minimum client version; identify as the
|
|
13
14
|
// same pinned CLI build Bubble verifies elsewhere.
|
|
@@ -57,3 +58,49 @@ export function createGrokSubscriptionFetch(auth, baseFetch = getChatGptFetch())
|
|
|
57
58
|
return baseFetch(input, { ...(init ?? {}), headers });
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Fetch the model list for a Grok subscription account. The CLI chat proxy
|
|
63
|
+
* gates on the grok-cli identity headers plus a fresh OAuth bearer, so this
|
|
64
|
+
* rides the same refreshing fetch used for chat requests instead of a raw
|
|
65
|
+
* global fetch. The /models payload carries richer metadata than id+name —
|
|
66
|
+
* `context_window`, `reasoning_efforts`, and the default `reasoning_effort` —
|
|
67
|
+
* which this surfaces so discovery can stop inferring them. Throws on a
|
|
68
|
+
* non-2xx so callers fall back to the curated catalog.
|
|
69
|
+
*/
|
|
70
|
+
export async function fetchGrokSubscriptionModels(baseURL, auth, options = {}) {
|
|
71
|
+
const fetchImpl = createGrokSubscriptionFetch(auth, options.fetch);
|
|
72
|
+
const base = baseURL.trim().replace(/\/+$/, "");
|
|
73
|
+
const response = await fetchImpl(`${base}/models`, {
|
|
74
|
+
headers: buildGrokSubscriptionHeaders(),
|
|
75
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5_000),
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`HTTP ${response.status}`);
|
|
79
|
+
const payload = (await response.json());
|
|
80
|
+
const entries = payload.data ?? payload.models ?? [];
|
|
81
|
+
return entries
|
|
82
|
+
.filter((entry) => typeof entry?.id === "string" && entry.id.trim().length > 0)
|
|
83
|
+
.map((entry) => {
|
|
84
|
+
const id = entry.id;
|
|
85
|
+
const rawEfforts = Array.isArray(entry.reasoning_efforts) ? entry.reasoning_efforts : [];
|
|
86
|
+
const reasoningEfforts = rawEfforts
|
|
87
|
+
.map((effort) => (effort && typeof effort === "object" && typeof effort.value === "string"
|
|
88
|
+
? effort.value
|
|
89
|
+
: undefined))
|
|
90
|
+
.filter((value) => typeof value === "string" && THINKING_LEVELS.includes(value))
|
|
91
|
+
.sort((a, b) => THINKING_LEVELS.indexOf(a) - THINKING_LEVELS.indexOf(b));
|
|
92
|
+
const defaultReasoningEffort = typeof entry.reasoning_effort === "string"
|
|
93
|
+
&& THINKING_LEVELS.includes(entry.reasoning_effort)
|
|
94
|
+
? entry.reasoning_effort
|
|
95
|
+
: undefined;
|
|
96
|
+
return {
|
|
97
|
+
id,
|
|
98
|
+
name: typeof entry.name === "string" ? entry.name : undefined,
|
|
99
|
+
contextWindow: typeof entry.context_window === "number" && entry.context_window > 0
|
|
100
|
+
? entry.context_window
|
|
101
|
+
: undefined,
|
|
102
|
+
reasoningEfforts: reasoningEfforts.length > 0 ? reasoningEfforts : undefined,
|
|
103
|
+
defaultReasoningEffort,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -9,7 +9,7 @@ import { type ProviderProtocol } from "./model-catalog.js";
|
|
|
9
9
|
import { ModelConfig } from "./model-config.js";
|
|
10
10
|
import { AuthStorage } from "./oauth/index.js";
|
|
11
11
|
import { type OpenAICodexAuthAdapter } from "./provider-openai-codex.js";
|
|
12
|
-
import type
|
|
12
|
+
import { type GrokAuthAdapter } from "./provider-grok.js";
|
|
13
13
|
import type { ThinkingLevel } from "./types.js";
|
|
14
14
|
export interface ProviderProfile {
|
|
15
15
|
id: string;
|
|
@@ -77,6 +77,7 @@ export declare class ProviderRegistry {
|
|
|
77
77
|
private modelConfig;
|
|
78
78
|
private authStorage;
|
|
79
79
|
private modelDiscoveryCache;
|
|
80
|
+
private readonly discoveryDiskCachePath;
|
|
80
81
|
private modelDiscoveryInFlight;
|
|
81
82
|
private modelDiscoveryGeneration;
|
|
82
83
|
/** Last membership seen per discovery key — survives cache eviction/TTL so
|
|
@@ -85,6 +86,14 @@ export declare class ProviderRegistry {
|
|
|
85
86
|
private routingRevision;
|
|
86
87
|
private identityFingerprints;
|
|
87
88
|
constructor(config: UserConfig);
|
|
89
|
+
/** The on-disk cache is production-only; disable it under the test runner so
|
|
90
|
+
* tests sharing one isolated BUBBLE_HOME never contaminate each other. */
|
|
91
|
+
private get discoveryDiskCacheEnabled();
|
|
92
|
+
/** Restore a prior successful discovery so dynamic models resolve without a
|
|
93
|
+
* network round-trip right after startup (no fetch on every launch). */
|
|
94
|
+
private loadDiscoveryDiskCache;
|
|
95
|
+
/** Persist successful discoveries so the next launch can reuse them. */
|
|
96
|
+
private saveDiscoveryDiskCache;
|
|
88
97
|
/**
|
|
89
98
|
* Monotonic counter of semantic routing-world changes: provider set,
|
|
90
99
|
* credential identity, provider key/config, discovery membership.
|
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* Reads provider configuration from models.json first, then falls back to config.json.
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { getBubbleHome } from "./bubble-home.js";
|
|
11
|
+
import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, clearDynamicModelMetadata, getBuiltinModel, getBuiltinProvider, inferGrokModelMetadata, listBuiltinModels, replaceDynamicModelMetadata, } from "./model-catalog.js";
|
|
9
12
|
import { ModelConfig } from "./model-config.js";
|
|
10
13
|
import { AuthStorage } from "./oauth/index.js";
|
|
11
14
|
import { fetchGeminiModels } from "./provider-ai-sdk.js";
|
|
12
15
|
import { extractChatGptAccountId, fetchOpenAICodexModelCatalog } from "./provider-openai-codex.js";
|
|
16
|
+
import { fetchGrokSubscriptionModels } from "./provider-grok.js";
|
|
13
17
|
import { refreshOpenAICodex } from "./oauth/openai-codex.js";
|
|
14
18
|
import { refreshGrok } from "./oauth/grok.js";
|
|
15
19
|
/** True for providers speaking the OpenAI chat-completions protocol (the default). */
|
|
@@ -59,6 +63,8 @@ export function sanitizeProviderHeaders(value) {
|
|
|
59
63
|
}
|
|
60
64
|
const MODEL_DISCOVERY_SUCCESS_TTL_MS = 60_000;
|
|
61
65
|
const MODEL_DISCOVERY_FAILURE_TTL_MS = 10_000;
|
|
66
|
+
/** How long a successful discovery survives on disk before a fresh fetch. */
|
|
67
|
+
const MODEL_DISCOVERY_DISK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
62
68
|
/** Discovery must never delay startup or a model picker for long. */
|
|
63
69
|
const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
64
70
|
export const BUILTIN_PROVIDERS = CATALOG_PROVIDERS;
|
|
@@ -73,6 +79,7 @@ export class ProviderRegistry {
|
|
|
73
79
|
modelConfig;
|
|
74
80
|
authStorage;
|
|
75
81
|
modelDiscoveryCache = new Map();
|
|
82
|
+
discoveryDiskCachePath = join(getBubbleHome(), "model-discovery-cache.json");
|
|
76
83
|
modelDiscoveryInFlight = new Map();
|
|
77
84
|
modelDiscoveryGeneration = new Map();
|
|
78
85
|
/** Last membership seen per discovery key — survives cache eviction/TTL so
|
|
@@ -90,6 +97,72 @@ export class ProviderRegistry {
|
|
|
90
97
|
this.identityFingerprints.set(authKey, fingerprint);
|
|
91
98
|
}
|
|
92
99
|
this.authStorage.onMutation((authKey) => this.handleAuthMutation(authKey));
|
|
100
|
+
this.loadDiscoveryDiskCache();
|
|
101
|
+
}
|
|
102
|
+
/** The on-disk cache is production-only; disable it under the test runner so
|
|
103
|
+
* tests sharing one isolated BUBBLE_HOME never contaminate each other. */
|
|
104
|
+
get discoveryDiskCacheEnabled() {
|
|
105
|
+
const v = process.env.VITEST?.trim().toLowerCase();
|
|
106
|
+
return v !== "true" && v !== "1";
|
|
107
|
+
}
|
|
108
|
+
/** Restore a prior successful discovery so dynamic models resolve without a
|
|
109
|
+
* network round-trip right after startup (no fetch on every launch). */
|
|
110
|
+
loadDiscoveryDiskCache() {
|
|
111
|
+
if (!this.discoveryDiskCacheEnabled)
|
|
112
|
+
return;
|
|
113
|
+
try {
|
|
114
|
+
if (!existsSync(this.discoveryDiskCachePath))
|
|
115
|
+
return;
|
|
116
|
+
const parsed = JSON.parse(readFileSync(this.discoveryDiskCachePath, "utf8"));
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
119
|
+
if (!entry || typeof entry.expiresAt !== "number" || entry.expiresAt <= now)
|
|
120
|
+
continue;
|
|
121
|
+
if (!entry.result || !Array.isArray(entry.result.models))
|
|
122
|
+
continue;
|
|
123
|
+
this.modelDiscoveryCache.set(key, {
|
|
124
|
+
result: entry.result,
|
|
125
|
+
expiresAt: entry.expiresAt,
|
|
126
|
+
identityKey: entry.identityKey ?? "unknown",
|
|
127
|
+
providerId: entry.providerId ?? "",
|
|
128
|
+
authType: entry.authType,
|
|
129
|
+
protocol: entry.protocol,
|
|
130
|
+
});
|
|
131
|
+
// Rebuild the dynamic overlay so context window / reasoning levels
|
|
132
|
+
// resolve from the cached catalog at startup, not only on /model open.
|
|
133
|
+
if (entry.providerId) {
|
|
134
|
+
this.applyDynamicDiscoveryMetadata({ id: entry.providerId, authType: entry.authType, protocol: entry.protocol }, entry.result);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Missing/corrupt cache is fine; the next discovery re-fetches.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Persist successful discoveries so the next launch can reuse them. */
|
|
143
|
+
saveDiscoveryDiskCache() {
|
|
144
|
+
if (!this.discoveryDiskCacheEnabled)
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
const data = {};
|
|
148
|
+
for (const [key, entry] of this.modelDiscoveryCache) {
|
|
149
|
+
if (entry.result.error)
|
|
150
|
+
continue;
|
|
151
|
+
data[key] = {
|
|
152
|
+
result: entry.result,
|
|
153
|
+
expiresAt: Date.now() + MODEL_DISCOVERY_DISK_TTL_MS,
|
|
154
|
+
identityKey: entry.identityKey,
|
|
155
|
+
providerId: entry.providerId,
|
|
156
|
+
authType: entry.authType,
|
|
157
|
+
protocol: entry.protocol,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
mkdirSync(dirname(this.discoveryDiskCachePath), { recursive: true });
|
|
161
|
+
writeFileSync(this.discoveryDiskCachePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Persistence is best-effort.
|
|
165
|
+
}
|
|
93
166
|
}
|
|
94
167
|
/**
|
|
95
168
|
* Monotonic counter of semantic routing-world changes: provider set,
|
|
@@ -419,7 +492,12 @@ export class ProviderRegistry {
|
|
|
419
492
|
result: { ...result, source: result.source === "cache" ? "remote" : result.source },
|
|
420
493
|
expiresAt: Date.now() + (result.error ? MODEL_DISCOVERY_FAILURE_TTL_MS : MODEL_DISCOVERY_SUCCESS_TTL_MS),
|
|
421
494
|
identityKey: this.discoveryIdentity(provider),
|
|
495
|
+
providerId: provider.id,
|
|
496
|
+
authType: provider.authType,
|
|
497
|
+
protocol: provider.protocol,
|
|
422
498
|
});
|
|
499
|
+
if (!result.error)
|
|
500
|
+
this.saveDiscoveryDiskCache();
|
|
423
501
|
this.lastDiscoveryMembership.set(key, {
|
|
424
502
|
ids: result.models.map((model) => model.id),
|
|
425
503
|
authoritative: result.authoritative,
|
|
@@ -521,6 +599,59 @@ export class ProviderRegistry {
|
|
|
521
599
|
return this.fallbackDiscovery(provider, error);
|
|
522
600
|
}
|
|
523
601
|
}
|
|
602
|
+
// Grok subscription: the CLI chat proxy gates on the grok-cli identity
|
|
603
|
+
// headers and a fresh OAuth bearer, neither of which the generic path below
|
|
604
|
+
// sends. Discover through the refreshing subscription fetch, then merge the
|
|
605
|
+
// curated catalog with any remote-only ids so newly-released models surface
|
|
606
|
+
// without a code change. Authoritative because the merged list is what the
|
|
607
|
+
// routing catalog and model picker should treat as the account's catalog.
|
|
608
|
+
if (provider.id === "grok" && provider.authType === "oauth" && provider.apiKey) {
|
|
609
|
+
try {
|
|
610
|
+
await this.prepareProvider("grok");
|
|
611
|
+
const currentProvider = this.getConfigured().find((item) => item.id === "grok") ?? provider;
|
|
612
|
+
const grokAuth = this.createGrokAuthAdapter("grok");
|
|
613
|
+
if (!grokAuth)
|
|
614
|
+
throw new Error("Grok OAuth credentials are unavailable.");
|
|
615
|
+
const remote = await fetchGrokSubscriptionModels(currentProvider.baseURL, grokAuth);
|
|
616
|
+
const local = this.localModelsForProvider(currentProvider);
|
|
617
|
+
const known = new Set(local.map((model) => model.id));
|
|
618
|
+
const extras = remote
|
|
619
|
+
.filter((entry) => !known.has(entry.id) && isLikelyChatModelId(entry.id))
|
|
620
|
+
.map((entry) => {
|
|
621
|
+
const inferred = inferGrokModelMetadata(entry.id);
|
|
622
|
+
// Prefer the server-declared effort ladder and context window over
|
|
623
|
+
// id-based inference; fall back to inference only when the endpoint
|
|
624
|
+
// omitted them (or for ids that don't match a known family).
|
|
625
|
+
const levels = entry.reasoningEfforts && entry.reasoningEfforts.length > 0
|
|
626
|
+
? entry.reasoningEfforts
|
|
627
|
+
: inferred.levels;
|
|
628
|
+
const defaultLevel = entry.defaultReasoningEffort && levels.includes(entry.defaultReasoningEffort)
|
|
629
|
+
? entry.defaultReasoningEffort
|
|
630
|
+
: inferred.defaultLevel;
|
|
631
|
+
return {
|
|
632
|
+
id: entry.id,
|
|
633
|
+
name: entry.name || entry.id,
|
|
634
|
+
providerId: currentProvider.id,
|
|
635
|
+
reasoningLevels: levels,
|
|
636
|
+
...(defaultLevel ? { defaultReasoningLevel: defaultLevel } : {}),
|
|
637
|
+
...(entry.contextWindow !== undefined
|
|
638
|
+
? { contextWindow: entry.contextWindow }
|
|
639
|
+
: inferred.contextWindow !== undefined
|
|
640
|
+
? { contextWindow: inferred.contextWindow }
|
|
641
|
+
: {}),
|
|
642
|
+
};
|
|
643
|
+
})
|
|
644
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
645
|
+
return {
|
|
646
|
+
models: [...local, ...extras],
|
|
647
|
+
source: "remote",
|
|
648
|
+
authoritative: true,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
return this.fallbackDiscovery(provider, error);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
524
655
|
// Generic OpenAI-compatible discovery: most vendors expose GET /models on
|
|
525
656
|
// the same base URL. Probed against every configured provider, their lists
|
|
526
657
|
// turned out to be neither complete nor clean — zhipuai omits glm-5.2,
|
|
@@ -642,7 +773,9 @@ export class ProviderRegistry {
|
|
|
642
773
|
? "openai-codex"
|
|
643
774
|
: provider.id === "google" && provider.protocol === "ai-sdk"
|
|
644
775
|
? "google"
|
|
645
|
-
:
|
|
776
|
+
: provider.id === "grok"
|
|
777
|
+
? "grok"
|
|
778
|
+
: undefined;
|
|
646
779
|
if (!dynamicProviderId)
|
|
647
780
|
return;
|
|
648
781
|
if (!result.authoritative) {
|
|
@@ -653,8 +786,11 @@ export class ProviderRegistry {
|
|
|
653
786
|
id: model.id,
|
|
654
787
|
name: model.name,
|
|
655
788
|
providerId: dynamicProviderId,
|
|
656
|
-
//
|
|
657
|
-
|
|
789
|
+
// A remote model with no trusted effort metadata still has to be
|
|
790
|
+
// selectable: default it to the plain "off" level (no reasoning control)
|
|
791
|
+
// rather than an empty ladder, which the model picker would render
|
|
792
|
+
// "unavailable" and skip during arrow-key navigation.
|
|
793
|
+
reasoningLevels: model.reasoningLevels ?? ["off"],
|
|
658
794
|
defaultReasoningLevel: model.defaultReasoningLevel,
|
|
659
795
|
contextWindow: model.contextWindow,
|
|
660
796
|
useResponsesLite: model.useResponsesLite,
|
package/dist/provider.js
CHANGED
|
@@ -397,6 +397,9 @@ function extractBalancedJson(s, start) {
|
|
|
397
397
|
* execute safely.
|
|
398
398
|
*/
|
|
399
399
|
export async function* translateOpenAIFullResponse(response) {
|
|
400
|
+
if (typeof response?.system_fingerprint === "string" && response.system_fingerprint) {
|
|
401
|
+
yield { type: "response_metadata", systemFingerprint: response.system_fingerprint };
|
|
402
|
+
}
|
|
400
403
|
const usageChunk = usageToStreamChunk(response?.usage);
|
|
401
404
|
if (usageChunk)
|
|
402
405
|
yield usageChunk;
|
|
@@ -502,6 +505,7 @@ export async function* translateOpenAIStream(stream, options = {}) {
|
|
|
502
505
|
let reasoningBuffer = "";
|
|
503
506
|
let textBuffer = "";
|
|
504
507
|
let rawChunkSeq = 0;
|
|
508
|
+
let systemFingerprint;
|
|
505
509
|
// DeepSeek (and some inference re-hosts) sometimes deliver reasoning twice:
|
|
506
510
|
// once via a dedicated `reasoning_content` / `thinking` field, and again
|
|
507
511
|
// embedded as `<think>...</think>` inside `delta.content`. Track whether we
|
|
@@ -553,6 +557,13 @@ export async function* translateOpenAIStream(stream, options = {}) {
|
|
|
553
557
|
const delta = choice?.delta;
|
|
554
558
|
const usage = chunk.usage ?? choice?.usage;
|
|
555
559
|
const finishReason = choice?.finish_reason;
|
|
560
|
+
if (typeof chunk?.system_fingerprint === "string"
|
|
561
|
+
&& chunk.system_fingerprint
|
|
562
|
+
&& chunk.system_fingerprint !== systemFingerprint) {
|
|
563
|
+
const nextSystemFingerprint = chunk.system_fingerprint;
|
|
564
|
+
systemFingerprint = nextSystemFingerprint;
|
|
565
|
+
yield { type: "response_metadata", systemFingerprint: nextSystemFingerprint };
|
|
566
|
+
}
|
|
556
567
|
debugReasoningStream({
|
|
557
568
|
stage: "provider_raw",
|
|
558
569
|
providerId: options.debugProviderId,
|
package/dist/session-log.js
CHANGED
|
@@ -203,6 +203,7 @@ function normalizeMessageToEntries(message, id, timestamp) {
|
|
|
203
203
|
providerId: message.providerId,
|
|
204
204
|
modelId: message.modelId,
|
|
205
205
|
usage: message.usage,
|
|
206
|
+
systemFingerprint: message.systemFingerprint,
|
|
206
207
|
error: message.error,
|
|
207
208
|
providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(message.providerMetadata)),
|
|
208
209
|
},
|
package/dist/tui-ink/app.js
CHANGED
|
@@ -22,6 +22,7 @@ import { SkillRegistry } from "../skills/registry.js";
|
|
|
22
22
|
import { parseSkillInvocation } from "../skills/invocation.js";
|
|
23
23
|
import { useTerminalSize } from "./use-terminal-size.js";
|
|
24
24
|
import { WelcomeBanner, shouldShowWelcomeBanner } from "./welcome.js";
|
|
25
|
+
import { BubbleCodeWordmark } from "./wordmark.js";
|
|
25
26
|
import { expandAtMentions } from "./file-mentions.js";
|
|
26
27
|
import { TodosPanel } from "./todos.js";
|
|
27
28
|
import { CompactionProgressCard } from "./compaction-progress.js";
|
|
@@ -56,6 +57,7 @@ import { GROK_SUBSCRIPTION_PROVIDER_ID, isGrokSubscriptionProviderId, withGrokSu
|
|
|
56
57
|
import { classifyExternalRuntimeBinding, stopExternalRuntimeForSessionSwitch, } from "../external-runtime/session-policy.js";
|
|
57
58
|
import os from "node:os";
|
|
58
59
|
import { rmSync } from "node:fs";
|
|
60
|
+
import { execFile } from "node:child_process";
|
|
59
61
|
function buildTips(agent, registry, grokActive = false) {
|
|
60
62
|
const tips = [];
|
|
61
63
|
if (grokActive) {
|
|
@@ -116,6 +118,25 @@ function truncate(value, max) {
|
|
|
116
118
|
return value;
|
|
117
119
|
return `${value.slice(0, Math.max(0, max - 1))}…`;
|
|
118
120
|
}
|
|
121
|
+
function formatCompactTokens(n) {
|
|
122
|
+
if (n < 1000)
|
|
123
|
+
return `${Math.round(n)}`;
|
|
124
|
+
if (n < 1_000_000)
|
|
125
|
+
return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}K`;
|
|
126
|
+
return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
|
127
|
+
}
|
|
128
|
+
function formatContextUsageLabel(snapshot) {
|
|
129
|
+
if (!snapshot.contextWindow || snapshot.contextWindow <= 0) {
|
|
130
|
+
return `${formatCompactTokens(snapshot.usedTokens)} context`;
|
|
131
|
+
}
|
|
132
|
+
const percent = (snapshot.usedTokens / snapshot.contextWindow) * 100;
|
|
133
|
+
const label = percent >= 10
|
|
134
|
+
? `${Math.round(percent)}%`
|
|
135
|
+
: percent >= 0.05
|
|
136
|
+
? `${percent.toFixed(1)}%`
|
|
137
|
+
: "<0.1%";
|
|
138
|
+
return `${label} context`;
|
|
139
|
+
}
|
|
119
140
|
export function reconstructDisplayMessages(agentMessages) {
|
|
120
141
|
const result = [];
|
|
121
142
|
for (const m of agentMessages) {
|
|
@@ -168,13 +189,14 @@ export function reconstructDisplayMessages(agentMessages) {
|
|
|
168
189
|
const content = interrupted
|
|
169
190
|
? stripInterruptedAssistantMarker(m.content, INTERRUPTED_ASSISTANT_CONTENT)
|
|
170
191
|
: m.content;
|
|
171
|
-
if (content || m.reasoning || toolCalls.length > 0) {
|
|
192
|
+
if (content || m.reasoning || toolCalls.length > 0 || m.systemFingerprint) {
|
|
172
193
|
result.push({
|
|
173
194
|
key: nextDisplayMessageKey("asst"),
|
|
174
195
|
role: "assistant",
|
|
175
196
|
content,
|
|
176
197
|
reasoning: m.reasoning || undefined,
|
|
177
198
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
199
|
+
systemFingerprint: m.systemFingerprint,
|
|
178
200
|
});
|
|
179
201
|
}
|
|
180
202
|
if (interrupted) {
|
|
@@ -328,21 +350,31 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
328
350
|
}, [subagentMembers.length, subagentEntryFocused]);
|
|
329
351
|
// Live progress for a manual `/compact` run (null when not compacting).
|
|
330
352
|
const [compaction, setCompaction] = useState(null);
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
? agent.model.split(":")
|
|
337
|
-
: [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
|
|
338
|
-
const providerId = modelParts[0];
|
|
339
|
-
const modelId = modelParts.slice(1).join(":");
|
|
340
|
-
const availableLevels = getAvailableThinkingLevels(providerId, modelId);
|
|
341
|
-
return normalizeThinkingLevel(agent.thinking, availableLevels);
|
|
342
|
-
});
|
|
353
|
+
// The reasoning level is persisted on the agent (agent.thinking); display it
|
|
354
|
+
// directly. switchAgentModel re-normalizes on explicit model switches, so the
|
|
355
|
+
// initial value needs no clamping against the (possibly not-yet-discovered)
|
|
356
|
+
// model ladder.
|
|
357
|
+
const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
|
|
343
358
|
const [permissionMode, setPermissionMode] = useState(agent.mode);
|
|
344
359
|
const [todos, setTodos] = useState(() => agent.getTodos());
|
|
345
360
|
const [goalLine, setGoalLine] = useState("");
|
|
361
|
+
const [branch, setBranch] = useState(undefined);
|
|
362
|
+
const [contextUsage, setContextUsage] = useState("");
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
let cancelled = false;
|
|
365
|
+
execFile("git", ["-C", args.cwd, "branch", "--show-current"], { timeout: 3000 }, (error, stdout) => {
|
|
366
|
+
if (cancelled)
|
|
367
|
+
return;
|
|
368
|
+
if (error)
|
|
369
|
+
return;
|
|
370
|
+
const name = stdout.trim();
|
|
371
|
+
setBranch(name || undefined);
|
|
372
|
+
});
|
|
373
|
+
return () => { cancelled = true; };
|
|
374
|
+
}, [args.cwd]);
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
|
|
377
|
+
}, [agent]);
|
|
346
378
|
const [currentUpdateNotice, setCurrentUpdateNotice] = useState(updateNotice);
|
|
347
379
|
const [pendingPlan, setPendingPlan] = useState(null);
|
|
348
380
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
@@ -1075,6 +1107,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
1075
1107
|
const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
|
|
1076
1108
|
? (isThinkingToggle || isThinkingOnly ? " in thinking mode" : ` with ${nextThinkingLevel} effort`)
|
|
1077
1109
|
: "";
|
|
1110
|
+
setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
|
|
1078
1111
|
addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`, "ui_notice");
|
|
1079
1112
|
closePicker();
|
|
1080
1113
|
return nextThinkingLevel;
|
|
@@ -1415,6 +1448,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
1415
1448
|
let runErrored = false;
|
|
1416
1449
|
const toolCalls = [];
|
|
1417
1450
|
const assistantParts = [];
|
|
1451
|
+
let assistantSystemFingerprint;
|
|
1418
1452
|
// Entries whose members all reached a final status are now covered by
|
|
1419
1453
|
// the settled transcript; drop them so the accumulator stays bounded
|
|
1420
1454
|
// across a long session. Running entries survive — for a workflow
|
|
@@ -1480,6 +1514,9 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
1480
1514
|
if (taskElapsedMs !== undefined && Number.isFinite(taskElapsedMs) && taskElapsedMs > 0) {
|
|
1481
1515
|
msg.taskElapsedMs = taskElapsedMs;
|
|
1482
1516
|
}
|
|
1517
|
+
if (assistantSystemFingerprint) {
|
|
1518
|
+
msg.systemFingerprint = assistantSystemFingerprint;
|
|
1519
|
+
}
|
|
1483
1520
|
updateDisplayMessages((prev) => [...prev, msg]);
|
|
1484
1521
|
if (runExternalRuntime) {
|
|
1485
1522
|
runSessionManager.appendMessage({
|
|
@@ -1502,6 +1539,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
1502
1539
|
setStreamingParts([]);
|
|
1503
1540
|
assistantContent = "";
|
|
1504
1541
|
assistantReasoning = "";
|
|
1542
|
+
assistantSystemFingerprint = undefined;
|
|
1505
1543
|
toolCalls.length = 0;
|
|
1506
1544
|
assistantParts.length = 0;
|
|
1507
1545
|
};
|
|
@@ -1719,6 +1757,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
1719
1757
|
goalRunUsageReported = true;
|
|
1720
1758
|
goalRunTokens += tokenUsageTotal(event.usage);
|
|
1721
1759
|
}
|
|
1760
|
+
assistantSystemFingerprint = event.systemFingerprint;
|
|
1761
|
+
setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
|
|
1722
1762
|
if (event.willContinue) {
|
|
1723
1763
|
commitAssistantMessage();
|
|
1724
1764
|
clearAssistantStream();
|
|
@@ -2375,7 +2415,16 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
2375
2415
|
const bannerThinkingLabel = grokSessionBound
|
|
2376
2416
|
? externalRuntimeBinding?.reasoningEffort
|
|
2377
2417
|
: !externalSessionBound && showThinkingLabel ? thinkingLevel : undefined;
|
|
2378
|
-
|
|
2418
|
+
// Footer surfaces the graded effort level too (grok/DeepSeek/OpenAI ladders),
|
|
2419
|
+
// but skips on/off toggles and thinking-only placeholders ("medium").
|
|
2420
|
+
const footerThinkingLabel = !externalSessionBound
|
|
2421
|
+
&& thinkingLevel
|
|
2422
|
+
&& thinkingLevel !== "off"
|
|
2423
|
+
&& !isThinkingToggle
|
|
2424
|
+
&& !isThinkingOnly
|
|
2425
|
+
? thinkingLevel
|
|
2426
|
+
: undefined;
|
|
2427
|
+
const welcomeBannerNode = showWelcome ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(BubbleCodeWordmark, { width: terminalColumns }), _jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: bannerTips, updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: bannerProviderId, modelId: bannerModelId, modelLabel: bannerModelLabel, thinkingLabel: bannerThinkingLabel })] })) : null;
|
|
2379
2428
|
const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry, externalSessionBound), [externalSessionBound, safeSkillRegistry]);
|
|
2380
2429
|
const mcpReconnectItems = useMemo(() => buildMcpReconnectItems(mcpManager), [mcpManager]);
|
|
2381
2430
|
// No fixed-height frame: settled rows flow into the terminal's native
|
|
@@ -2502,6 +2551,15 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
2502
2551
|
? `Grok Subscription${externalRuntimeBinding?.modelId ? ` · ${externalRuntimeBinding.modelId}` : ""}${externalRuntimeBinding?.reasoningEffort && externalRuntimeBinding.reasoningEffort !== "off" ? ` · ${externalRuntimeBinding.reasoningEffort}` : ""} · workspace`
|
|
2503
2552
|
: "Unsupported external runtime · recovery-only")
|
|
2504
2553
|
: undefined,
|
|
2554
|
+
cwd: friendlyCwd(args.cwd),
|
|
2555
|
+
branch,
|
|
2556
|
+
model: externalSessionBound
|
|
2557
|
+
? undefined
|
|
2558
|
+
: (agent.model
|
|
2559
|
+
? `${displayModel(agent.model)}${footerThinkingLabel ? ` · ${footerThinkingLabel}` : ""}`
|
|
2560
|
+
: undefined),
|
|
2561
|
+
sessionTitle: externalSessionBound ? undefined : (sessionManager?.getMetadata().title?.trim() || undefined),
|
|
2562
|
+
contextUsage: externalSessionBound ? undefined : (contextUsage || undefined),
|
|
2505
2563
|
}) }) }))] }) }));
|
|
2506
2564
|
}
|
|
2507
2565
|
function buildCommandPaletteItems(skillRegistry, grokSessionBound = false) {
|
|
@@ -17,6 +17,8 @@ export interface DisplayMessage {
|
|
|
17
17
|
compactionSummary?: string;
|
|
18
18
|
hiddenCount?: number;
|
|
19
19
|
taskElapsedMs?: number;
|
|
20
|
+
/** Backend configuration fingerprint reported for this model response. */
|
|
21
|
+
systemFingerprint?: string;
|
|
20
22
|
}
|
|
21
23
|
export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
|
|
22
24
|
export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
|
package/dist/tui-ink/footer.d.ts
CHANGED
|
@@ -3,11 +3,22 @@ export interface FooterData {
|
|
|
3
3
|
mode?: PermissionMode;
|
|
4
4
|
goalLine?: string;
|
|
5
5
|
runtimeLabel?: string;
|
|
6
|
+
/** Friendly working directory (e.g. `~/project`). */
|
|
7
|
+
cwd?: string;
|
|
8
|
+
/** Current git branch, when the cwd is a git repo. */
|
|
9
|
+
branch?: string;
|
|
10
|
+
/** Display name of the active model. */
|
|
11
|
+
model?: string;
|
|
12
|
+
/** Current session title, when one has been assigned. */
|
|
13
|
+
sessionTitle?: string;
|
|
14
|
+
/** Compact context usage, e.g. `42K/500K`. */
|
|
15
|
+
contextUsage?: string;
|
|
6
16
|
}
|
|
7
17
|
/**
|
|
8
|
-
* Bottom status line.
|
|
9
|
-
*
|
|
10
|
-
* permission-mode badge
|
|
18
|
+
* Bottom status line. Always surfaces a `cwd | branch | model | title | ctx`
|
|
19
|
+
* row (each segment only when its data is present), and appends the
|
|
20
|
+
* permission-mode badge for non-default modes. Renders nothing (zero rows)
|
|
21
|
+
* when no data is available.
|
|
11
22
|
*/
|
|
12
23
|
export declare function FooterBar({ data }: {
|
|
13
24
|
data: FooterData;
|
package/dist/tui-ink/footer.js
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
2
3
|
import { Box, Text } from "ink";
|
|
3
4
|
import { useTheme } from "./theme.js";
|
|
4
5
|
import { PERMISSION_MODE_INFO } from "../permission/mode.js";
|
|
5
6
|
/**
|
|
6
|
-
* Bottom status line.
|
|
7
|
-
*
|
|
8
|
-
* permission-mode badge
|
|
7
|
+
* Bottom status line. Always surfaces a `cwd | branch | model | title | ctx`
|
|
8
|
+
* row (each segment only when its data is present), and appends the
|
|
9
|
+
* permission-mode badge for non-default modes. Renders nothing (zero rows)
|
|
10
|
+
* when no data is available.
|
|
9
11
|
*/
|
|
10
12
|
export function FooterBar({ data }) {
|
|
13
|
+
const theme = useTheme();
|
|
11
14
|
const showMode = !!data.mode && data.mode !== "default";
|
|
12
15
|
const goalLine = data.goalLine?.trim();
|
|
13
16
|
const runtimeLabel = data.runtimeLabel?.trim();
|
|
14
|
-
|
|
17
|
+
const segments = [];
|
|
18
|
+
if (data.cwd?.trim())
|
|
19
|
+
segments.push({ key: "cwd", text: data.cwd.trim(), color: theme.muted });
|
|
20
|
+
if (data.branch?.trim())
|
|
21
|
+
segments.push({ key: "branch", text: data.branch.trim(), color: theme.muted });
|
|
22
|
+
if (data.model?.trim())
|
|
23
|
+
segments.push({ key: "model", text: data.model.trim(), color: theme.accent });
|
|
24
|
+
if (data.sessionTitle?.trim())
|
|
25
|
+
segments.push({ key: "title", text: data.sessionTitle.trim(), color: theme.muted });
|
|
26
|
+
if (data.contextUsage?.trim())
|
|
27
|
+
segments.push({ key: "ctx", text: data.contextUsage.trim(), color: theme.muted });
|
|
28
|
+
if (!showMode && !goalLine && !runtimeLabel && segments.length === 0)
|
|
15
29
|
return null;
|
|
16
|
-
return (_jsxs(Box, { paddingX: 1, flexShrink: 0, flexDirection: "column", children: [goalLine && _jsx(GoalBadge, { line: goalLine }), runtimeLabel && _jsx(RuntimeBadge, { label: runtimeLabel }), showMode && _jsx(ModeBadge, { mode: data.mode })] }));
|
|
30
|
+
return (_jsxs(Box, { paddingX: 1, flexShrink: 0, flexDirection: "column", children: [goalLine && _jsx(GoalBadge, { line: goalLine }), runtimeLabel && _jsx(RuntimeBadge, { label: runtimeLabel }), (segments.length > 0 || showMode) && (_jsxs(Box, { children: [showMode && (_jsxs(React.Fragment, { children: [_jsx(ModeBadge, { mode: data.mode }), segments.length > 0 && _jsx(Text, { color: theme.muted, children: " | " })] })), segments.map((segment, index) => (_jsxs(React.Fragment, { children: [index > 0 && _jsx(Text, { color: theme.muted, children: " | " }), _jsx(Text, { color: segment.color, children: segment.text })] }, segment.key)))] }))] }));
|
|
17
31
|
}
|
|
18
32
|
function RuntimeBadge({ label }) {
|
|
19
33
|
const theme = useTheme();
|
|
@@ -8,7 +8,7 @@ import { useTheme } from "./theme.js";
|
|
|
8
8
|
import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
|
|
9
9
|
import { bareImageFilenameFromPaste, extractImagePathTokens, ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
|
|
10
10
|
import { appendHistoryEntry, loadHistoryEntriesSync, pushHistoryEntry, stepHistory, } from "./input-history.js";
|
|
11
|
-
import { cloneComposerBuffer, createComposerBuffer, deleteComposerBackward, deleteComposerForward, expandComposerBuffer,
|
|
11
|
+
import { cloneComposerBuffer, createComposerBuffer, deleteComposerBackward, deleteComposerForward, expandComposerBuffer, insertComposerPaste, insertComposerText, moveComposerCursor, replaceComposerRange, } from "./composer-buffer.js";
|
|
12
12
|
import { isKeyReleaseEvent } from "./key-events.js";
|
|
13
13
|
import { PasteOperationTracker } from "./paste-operation-tracker.js";
|
|
14
14
|
import { stripTerminalMouseSequences } from "./terminal-mouse.js";
|
|
@@ -26,6 +26,18 @@ export function createComposerDraftSnapshot(input) {
|
|
|
26
26
|
imageLabelStartOverride: input.imageLabelStartOverride,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Rebuild a composer buffer from a persisted history entry. Text long enough to
|
|
31
|
+
* have been collapsed at paste time is collapsed again into a `[Pasted text #N …]`
|
|
32
|
+
* marker, but its full content is preserved in the buffer's paste spans so the
|
|
33
|
+
* composer stays compact, no content is lost, and the next submit re-expands it.
|
|
34
|
+
*/
|
|
35
|
+
function bufferFromHistoryText(text) {
|
|
36
|
+
if (shouldCollapsePastedContent(text)) {
|
|
37
|
+
return insertComposerPaste(createComposerBuffer(), text);
|
|
38
|
+
}
|
|
39
|
+
return createComposerBuffer(text);
|
|
40
|
+
}
|
|
29
41
|
/**
|
|
30
42
|
* Browse persisted history without turning marker-shaped text into live paste
|
|
31
43
|
* tokens, while preserving the exact unsent draft in memory for the final
|
|
@@ -68,7 +80,7 @@ export function stepComposerHistory(state, history, direction) {
|
|
|
68
80
|
};
|
|
69
81
|
}
|
|
70
82
|
return {
|
|
71
|
-
buffer:
|
|
83
|
+
buffer: bufferFromHistoryText(result.text),
|
|
72
84
|
attachments: (result.images ?? []).map((attachment) => ({ ...attachment })),
|
|
73
85
|
imageLabelStartOverride: result.imageDisplayStart ?? null,
|
|
74
86
|
historyIndex: result.index,
|
|
@@ -716,10 +728,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDo
|
|
|
716
728
|
return;
|
|
717
729
|
submittedPayloadFingerprintRef.current = fingerprint;
|
|
718
730
|
deliver(payload);
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
if (
|
|
731
|
+
// Always record the submitted text so ↑/↓ can recall it. The full expanded
|
|
732
|
+
// text is persisted (content is never lost); on recall, long text is
|
|
733
|
+
// re-collapsed into a paste marker that still carries its full content.
|
|
734
|
+
if (expandedText.trim().length > 0 || attachments.length > 0) {
|
|
723
735
|
const historyEntry = {
|
|
724
736
|
text: expandedText,
|
|
725
737
|
images: attachments,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
/**
|
|
4
|
+
* Retro terminal pixel-art wordmark: "BUBBLE CODE" rendered in a 5×7 block
|
|
5
|
+
* font with a light-blue front face and a dark-blue offset shadow (extrusion),
|
|
6
|
+
* plus a pale highlight along the top edge. Purely decorative; keeps the
|
|
7
|
+
* terminal background.
|
|
8
|
+
*/
|
|
9
|
+
const LETTER_W = 5;
|
|
10
|
+
const LETTER_H = 7;
|
|
11
|
+
const LETTER_GAP = 2;
|
|
12
|
+
const WORD_GAP = 4;
|
|
13
|
+
const SHADOW_DX = 1;
|
|
14
|
+
const SHADOW_DY = 1;
|
|
15
|
+
const FRONT = "#E0E6ED";
|
|
16
|
+
const SHADOW = "#3A4A5A";
|
|
17
|
+
const HIGHLIGHT = "#FFFFFF";
|
|
18
|
+
// 5×7 bold block pixel font ("1" = filled, "0" = empty).
|
|
19
|
+
const FONT = {
|
|
20
|
+
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
|
|
21
|
+
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
|
22
|
+
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
|
|
23
|
+
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
|
24
|
+
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
|
|
25
|
+
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
|
26
|
+
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
|
27
|
+
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
|
|
28
|
+
};
|
|
29
|
+
const WORD = "BUBBLE CODE";
|
|
30
|
+
function letterOffsets() {
|
|
31
|
+
const offsets = [];
|
|
32
|
+
let x = 0;
|
|
33
|
+
for (const ch of WORD) {
|
|
34
|
+
offsets.push({ ch, x });
|
|
35
|
+
x += ch === " " ? WORD_GAP : LETTER_W + LETTER_GAP;
|
|
36
|
+
}
|
|
37
|
+
return offsets;
|
|
38
|
+
}
|
|
39
|
+
export function buildBubbleCodeGrid() {
|
|
40
|
+
const offsets = letterOffsets();
|
|
41
|
+
const last = offsets[offsets.length - 1];
|
|
42
|
+
const width = last.x + LETTER_W + SHADOW_DX;
|
|
43
|
+
const height = LETTER_H + SHADOW_DY;
|
|
44
|
+
const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => ({ char: " ", color: null })));
|
|
45
|
+
const set = (x, y, color) => {
|
|
46
|
+
if (x >= 0 && x < width && y >= 0 && y < height) {
|
|
47
|
+
grid[y][x] = { char: "█", color };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const forEachPixel = (cb) => {
|
|
51
|
+
for (const { ch, x } of offsets) {
|
|
52
|
+
const glyph = FONT[ch] ?? FONT[" "];
|
|
53
|
+
for (let r = 0; r < LETTER_H; r++) {
|
|
54
|
+
for (let c = 0; c < LETTER_W; c++) {
|
|
55
|
+
if (glyph[r][c] === "1")
|
|
56
|
+
cb(x + c, r);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
// Extrusion (dark blue), offset one cell right + one down.
|
|
62
|
+
forEachPixel((x, y) => set(x + SHADOW_DX, y + SHADOW_DY, SHADOW));
|
|
63
|
+
// Front face (light blue).
|
|
64
|
+
forEachPixel((x, y) => set(x, y, FRONT));
|
|
65
|
+
// Top-edge highlight (pale blue) for a bit of shine.
|
|
66
|
+
for (const { ch, x } of offsets) {
|
|
67
|
+
const glyph = FONT[ch] ?? FONT[" "];
|
|
68
|
+
for (let c = 0; c < LETTER_W; c++) {
|
|
69
|
+
if (glyph[0][c] === "1")
|
|
70
|
+
set(x + c, 0, HIGHLIGHT);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return grid;
|
|
74
|
+
}
|
|
75
|
+
function mergeRow(row) {
|
|
76
|
+
const segments = [];
|
|
77
|
+
let current = null;
|
|
78
|
+
for (const cell of row) {
|
|
79
|
+
if (current && current.color === cell.color) {
|
|
80
|
+
current.text += cell.char;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
current = { text: cell.char, color: cell.color };
|
|
84
|
+
segments.push(current);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Drop trailing empty cells so the wordmark has no trailing whitespace.
|
|
88
|
+
while (segments.length > 0 && segments[segments.length - 1].color === null) {
|
|
89
|
+
segments.pop();
|
|
90
|
+
}
|
|
91
|
+
return segments;
|
|
92
|
+
}
|
|
93
|
+
export function BubbleCodeWordmark({ width }) {
|
|
94
|
+
const grid = buildBubbleCodeGrid();
|
|
95
|
+
const contentWidth = grid[0]?.length ?? 0;
|
|
96
|
+
const leftPad = width && width > contentWidth
|
|
97
|
+
? Math.floor((width - contentWidth) / 2)
|
|
98
|
+
: 0;
|
|
99
|
+
return (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: leftPad, children: grid.map((row, rowIndex) => {
|
|
100
|
+
const segments = mergeRow(row);
|
|
101
|
+
return (_jsx(Box, { flexDirection: "row", flexShrink: 0, children: segments.map((segment, segmentIndex) => (_jsx(Text, { color: segment.color ?? undefined, children: segment.text }, segmentIndex))) }, rowIndex));
|
|
102
|
+
}) }));
|
|
103
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface AssistantMessage {
|
|
|
47
47
|
providerId?: string;
|
|
48
48
|
modelId?: string;
|
|
49
49
|
usage?: TokenUsage;
|
|
50
|
+
/** Backend configuration fingerprint reported by the model provider. */
|
|
51
|
+
systemFingerprint?: string;
|
|
50
52
|
error?: {
|
|
51
53
|
name: string;
|
|
52
54
|
message: string;
|
|
@@ -310,6 +312,9 @@ export type StreamChunk = {
|
|
|
310
312
|
} | {
|
|
311
313
|
type: "usage";
|
|
312
314
|
usage: TokenUsage;
|
|
315
|
+
} | {
|
|
316
|
+
type: "response_metadata";
|
|
317
|
+
systemFingerprint: string;
|
|
313
318
|
} | {
|
|
314
319
|
type: "done";
|
|
315
320
|
};
|
|
@@ -430,6 +435,7 @@ export type AgentEvent = {
|
|
|
430
435
|
type: "turn_end";
|
|
431
436
|
usage?: TokenUsage;
|
|
432
437
|
cost?: UsageCost;
|
|
438
|
+
systemFingerprint?: string;
|
|
433
439
|
willContinue?: boolean;
|
|
434
440
|
} | {
|
|
435
441
|
type: "context_recovered";
|