@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 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;
@@ -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;
@@ -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
- return BUILTIN_MODELS.find((model) => model.providerId === providerId && model.id === modelId)
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
- return getBuiltinModel(providerId, modelId)?.contextWindow;
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;
@@ -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[]>;
@@ -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 { GrokAuthAdapter } from "./provider-grok.js";
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 { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, clearDynamicModelMetadata, getBuiltinModel, getBuiltinProvider, listBuiltinModels, replaceDynamicModelMetadata, } from "./model-catalog.js";
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
- : undefined;
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
- // Empty means the remote model exists but declared no trusted effort metadata.
657
- reasoningLevels: model.reasoningLevels ?? [],
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,
@@ -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
  },
@@ -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
- // Normalize agent.thinking against the current model's supported levels so the
332
- // banner displays the *effective* level, not a stale user-config value like
333
- // "xhigh" when switching to a model that only supports ["high","max","off"].
334
- const [thinkingLevel, setThinkingLevel] = useState(() => {
335
- const modelParts = agent.model.includes(":")
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
- const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: bannerTips, updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: bannerProviderId, modelId: bannerModelId, modelLabel: bannerModelLabel, thinkingLabel: bannerThinkingLabel })) : null;
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;
@@ -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. Path / provider / model live in the welcome banner and
9
- * the model-switch transcript notices; the footer only surfaces the
10
- * permission-mode badge, so it renders nothing (zero rows) in the default mode.
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;
@@ -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. Path / provider / model live in the welcome banner and
7
- * the model-switch transcript notices; the footer only surfaces the
8
- * permission-mode badge, so it renders nothing (zero rows) in the default mode.
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
- if (!showMode && !goalLine && !runtimeLabel)
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, hasActiveComposerPastes, insertComposerPaste, insertComposerText, moveComposerCursor, replaceComposerRange, } from "./composer-buffer.js";
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: createComposerBuffer(result.text),
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
- // A collapsed text-paste marker cannot be safely replayed once its
720
- // in-memory reference is gone; skip those. Image-label stripping is fine to
721
- // replay (the attachments are stored on the history entry).
722
- if (!hasActiveComposerPastes(submittedBuffer) && (expandedText.trim().length > 0 || attachments.length > 0)) {
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,8 @@
1
+ export interface WordmarkCell {
2
+ char: string;
3
+ color: string | null;
4
+ }
5
+ export declare function buildBubbleCodeGrid(): WordmarkCell[][];
6
+ export declare function BubbleCodeWordmark({ width }: {
7
+ width?: number;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {