@bubblebrain-ai/bubble 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +18 -0
  14. package/dist/agent.js +188 -16
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -2
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.d.ts +34 -0
  29. package/dist/context/usage.js +213 -0
  30. package/dist/diff-stats.d.ts +5 -0
  31. package/dist/diff-stats.js +21 -0
  32. package/dist/main.js +68 -7
  33. package/dist/mcp/transports.d.ts +1 -0
  34. package/dist/mcp/transports.js +8 -0
  35. package/dist/model-catalog.d.ts +9 -0
  36. package/dist/model-catalog.js +17 -1
  37. package/dist/orchestrator/default-hooks.js +24 -18
  38. package/dist/prompt/compose.js +2 -1
  39. package/dist/prompt/provider-prompts/kimi.js +3 -1
  40. package/dist/provider-openai-codex.d.ts +13 -2
  41. package/dist/provider-openai-codex.js +81 -32
  42. package/dist/provider-registry.js +22 -6
  43. package/dist/provider-transform.d.ts +3 -1
  44. package/dist/provider-transform.js +15 -0
  45. package/dist/provider.d.ts +4 -1
  46. package/dist/provider.js +89 -4
  47. package/dist/reasoning-debug.d.ts +7 -0
  48. package/dist/reasoning-debug.js +30 -0
  49. package/dist/session-log.js +13 -2
  50. package/dist/session-types.d.ts +1 -1
  51. package/dist/slash-commands/commands.js +60 -2
  52. package/dist/slash-commands/types.d.ts +7 -0
  53. package/dist/tools/agent-lifecycle.js +22 -4
  54. package/dist/tools/edit.js +7 -2
  55. package/dist/tools/file-state.d.ts +19 -0
  56. package/dist/tools/file-state.js +15 -0
  57. package/dist/tools/glob.js +2 -1
  58. package/dist/tools/grep.js +2 -2
  59. package/dist/tools/lsp.js +2 -2
  60. package/dist/tools/path-utils.d.ts +2 -0
  61. package/dist/tools/path-utils.js +16 -0
  62. package/dist/tools/read.d.ts +1 -1
  63. package/dist/tools/read.js +207 -14
  64. package/dist/tools/write.js +3 -2
  65. package/dist/tui/escape-confirmation.d.ts +15 -0
  66. package/dist/tui/escape-confirmation.js +30 -0
  67. package/dist/tui/run.js +93 -23
  68. package/dist/tui-ink/app.d.ts +52 -0
  69. package/dist/tui-ink/app.js +1129 -0
  70. package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
  71. package/dist/tui-ink/approval/approval-dialog.js +132 -0
  72. package/dist/tui-ink/approval/diff-view.d.ts +7 -0
  73. package/dist/tui-ink/approval/diff-view.js +44 -0
  74. package/dist/tui-ink/approval/select.d.ts +35 -0
  75. package/dist/tui-ink/approval/select.js +88 -0
  76. package/dist/tui-ink/code-highlight.d.ts +8 -0
  77. package/dist/tui-ink/code-highlight.js +122 -0
  78. package/dist/tui-ink/detect-theme.d.ts +19 -0
  79. package/dist/tui-ink/detect-theme.js +123 -0
  80. package/dist/tui-ink/display-history.d.ts +38 -0
  81. package/dist/tui-ink/display-history.js +130 -0
  82. package/dist/tui-ink/edit-diff.d.ts +11 -0
  83. package/dist/tui-ink/edit-diff.js +52 -0
  84. package/dist/tui-ink/file-mentions.d.ts +29 -0
  85. package/dist/tui-ink/file-mentions.js +174 -0
  86. package/dist/tui-ink/footer.d.ts +19 -0
  87. package/dist/tui-ink/footer.js +45 -0
  88. package/dist/tui-ink/image-paste.d.ts +54 -0
  89. package/dist/tui-ink/image-paste.js +288 -0
  90. package/dist/tui-ink/input-box.d.ts +41 -0
  91. package/dist/tui-ink/input-box.js +694 -0
  92. package/dist/tui-ink/input-history.d.ts +16 -0
  93. package/dist/tui-ink/input-history.js +81 -0
  94. package/dist/tui-ink/markdown.d.ts +38 -0
  95. package/dist/tui-ink/markdown.js +394 -0
  96. package/dist/tui-ink/message-list.d.ts +33 -0
  97. package/dist/tui-ink/message-list.js +667 -0
  98. package/dist/tui-ink/model-picker.d.ts +43 -0
  99. package/dist/tui-ink/model-picker.js +331 -0
  100. package/dist/tui-ink/plan-confirm.d.ts +7 -0
  101. package/dist/tui-ink/plan-confirm.js +105 -0
  102. package/dist/tui-ink/question-dialog.d.ts +8 -0
  103. package/dist/tui-ink/question-dialog.js +99 -0
  104. package/dist/tui-ink/recent-activity.d.ts +8 -0
  105. package/dist/tui-ink/recent-activity.js +71 -0
  106. package/dist/tui-ink/run.d.ts +37 -0
  107. package/dist/tui-ink/run.js +53 -0
  108. package/dist/tui-ink/theme.d.ts +66 -0
  109. package/dist/tui-ink/theme.js +115 -0
  110. package/dist/tui-ink/todos.d.ts +7 -0
  111. package/dist/tui-ink/todos.js +46 -0
  112. package/dist/tui-ink/trace-groups.d.ts +27 -0
  113. package/dist/tui-ink/trace-groups.js +389 -0
  114. package/dist/tui-ink/use-terminal-size.d.ts +4 -0
  115. package/dist/tui-ink/use-terminal-size.js +21 -0
  116. package/dist/tui-ink/welcome.d.ts +18 -0
  117. package/dist/tui-ink/welcome.js +138 -0
  118. package/dist/types.d.ts +10 -0
  119. package/package.json +7 -1
@@ -2,7 +2,10 @@ import { listBuiltinModels } from "./model-catalog.js";
2
2
  import { resolveProviderRequestConfig } from "./provider-transform.js";
3
3
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
4
4
  const OPENAI_BETA_RESPONSES = "responses=experimental";
5
- const CODEX_CLIENT_VERSION = "0.121.0";
5
+ // OpenAI gates new codex models server-side by client_version (each model carries a
6
+ // `minimal_client_version`). Track a recent real Codex CLI release; override via env
7
+ // when OpenAI lifts the gate again before we cut a new release.
8
+ const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.150.0";
6
9
  const MODEL_DISCOVERY_PATHS = [
7
10
  `/codex/models?client_version=${CODEX_CLIENT_VERSION}`,
8
11
  "/models",
@@ -205,9 +208,9 @@ export async function fetchOpenAICodexModels(options) {
205
208
  if (!response?.ok)
206
209
  continue;
207
210
  const payload = await response.json().catch(() => undefined);
208
- const ids = extractModelIds(payload);
209
- if (ids.length > 0) {
210
- return sortCodexModelIds(ids);
211
+ const descriptors = extractCodexModelDescriptors(payload);
212
+ if (descriptors.length > 0) {
213
+ return sortCodexModelDescriptors(descriptors);
211
214
  }
212
215
  }
213
216
  return [];
@@ -361,18 +364,54 @@ function resolveRelativeUrl(baseURL, path) {
361
364
  const normalized = (baseURL.trim() || DEFAULT_CODEX_BASE_URL).replace(/\/+$/, "");
362
365
  return `${normalized}${path}`;
363
366
  }
364
- function extractModelIds(payload) {
365
- const ids = [];
367
+ const REASONING_EFFORTS = [
368
+ "off", "minimal", "low", "medium", "high", "xhigh", "max",
369
+ ];
370
+ function extractCodexModelDescriptors(payload) {
371
+ const out = [];
366
372
  const seen = new Set();
367
- const maybeAdd = (value) => {
368
- if (typeof value !== "string")
369
- return;
370
- if (!/^gpt-|^codex-/i.test(value))
371
- return;
372
- if (seen.has(value))
373
- return;
374
- seen.add(value);
375
- ids.push(value);
373
+ const isCodexId = (value) => typeof value === "string" && /^gpt-|^codex-/i.test(value);
374
+ const pickId = (record) => {
375
+ for (const key of ["slug", "id", "model_slug", "model"]) {
376
+ const v = record[key];
377
+ if (isCodexId(v))
378
+ return v;
379
+ }
380
+ return undefined;
381
+ };
382
+ const buildDescriptor = (record, id) => {
383
+ const desc = { id };
384
+ const displayName = record.display_name;
385
+ if (typeof displayName === "string" && displayName)
386
+ desc.displayName = displayName;
387
+ const ctx = record.context_window;
388
+ if (typeof ctx === "number" && ctx > 0)
389
+ desc.contextWindow = ctx;
390
+ const visibility = record.visibility;
391
+ if (typeof visibility === "string")
392
+ desc.visibility = visibility;
393
+ const minVer = record.minimal_client_version;
394
+ if (typeof minVer === "string")
395
+ desc.minimalClientVersion = minVer;
396
+ const levels = record.supported_reasoning_levels;
397
+ if (Array.isArray(levels)) {
398
+ const efforts = new Set(["off"]);
399
+ for (const level of levels) {
400
+ const effort = level?.effort;
401
+ if (typeof effort === "string" && REASONING_EFFORTS.includes(effort)) {
402
+ efforts.add(effort);
403
+ }
404
+ }
405
+ desc.reasoningLevels = REASONING_EFFORTS.filter((e) => efforts.has(e));
406
+ }
407
+ const truncPolicy = record.truncation_policy;
408
+ if (truncPolicy && truncPolicy.mode === "tokens") {
409
+ const limit = truncPolicy.limit;
410
+ if (typeof limit === "number" && limit > 0) {
411
+ desc.toolOutputTokenLimit = limit;
412
+ }
413
+ }
414
+ return desc;
376
415
  };
377
416
  const visit = (value) => {
378
417
  if (Array.isArray(value)) {
@@ -380,32 +419,42 @@ function extractModelIds(payload) {
380
419
  visit(item);
381
420
  return;
382
421
  }
383
- if (!value || typeof value !== "object") {
384
- maybeAdd(value);
422
+ if (!value || typeof value !== "object")
385
423
  return;
386
- }
387
424
  const record = value;
388
- maybeAdd(record.id);
389
- maybeAdd(record.slug);
390
- maybeAdd(record.model);
391
- maybeAdd(record.model_slug);
425
+ const id = pickId(record);
426
+ if (id && !seen.has(id)) {
427
+ seen.add(id);
428
+ out.push(buildDescriptor(record, id));
429
+ }
392
430
  for (const child of Object.values(record)) {
393
- if (child !== record.id && child !== record.slug && child !== record.model && child !== record.model_slug) {
431
+ if (child && typeof child === "object")
394
432
  visit(child);
395
- }
396
433
  }
397
434
  };
398
435
  visit(payload);
399
- return ids;
436
+ return out;
437
+ }
438
+ // Extracts the family version from a codex slug (e.g. "gpt-5.5-codex" → 5005).
439
+ // Used so models from a newer family float to the top even before the static
440
+ // catalog knows about them.
441
+ function parseCodexFamilyRank(id) {
442
+ const match = id.match(/(\d+)\.(\d+)/);
443
+ if (!match)
444
+ return 0;
445
+ return parseInt(match[1], 10) * 1000 + parseInt(match[2], 10);
400
446
  }
401
- function sortCodexModelIds(ids) {
402
- const preferredModels = getOpenAICodexFallbackModels();
403
- const preferred = new Map(preferredModels.map((id, index) => [id, index]));
404
- return [...ids].sort((left, right) => {
405
- const leftRank = preferred.get(left) ?? Number.MAX_SAFE_INTEGER;
406
- const rightRank = preferred.get(right) ?? Number.MAX_SAFE_INTEGER;
447
+ export function sortCodexModelDescriptors(descriptors) {
448
+ const preferred = new Map(getOpenAICodexFallbackModels().map((id, index) => [id, index]));
449
+ return [...descriptors].sort((left, right) => {
450
+ const leftFamily = parseCodexFamilyRank(left.id);
451
+ const rightFamily = parseCodexFamilyRank(right.id);
452
+ if (leftFamily !== rightFamily)
453
+ return rightFamily - leftFamily;
454
+ const leftRank = preferred.get(left.id) ?? Number.MAX_SAFE_INTEGER;
455
+ const rightRank = preferred.get(right.id) ?? Number.MAX_SAFE_INTEGER;
407
456
  if (leftRank !== rightRank)
408
457
  return leftRank - rightRank;
409
- return left.localeCompare(right);
458
+ return left.id.localeCompare(right.id);
410
459
  });
411
460
  }
@@ -4,7 +4,7 @@
4
4
  * Supports OpenAI-compatible providers with dynamic or static model lists.
5
5
  * Reads provider configuration from models.json first, then falls back to config.json.
6
6
  */
7
- import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, getBuiltinProvider, listBuiltinModels, } from "./model-catalog.js";
7
+ import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, getBuiltinModel, getBuiltinProvider, listBuiltinModels, registerDynamicModelMetadata, } from "./model-catalog.js";
8
8
  import { ModelConfig } from "./model-config.js";
9
9
  import { AuthStorage } from "./oauth/index.js";
10
10
  import { fetchOpenAICodexModels } from "./provider-openai-codex.js";
@@ -194,12 +194,28 @@ export class ProviderRegistry {
194
194
  }
195
195
  if (provider.id === "openai" && provider.authType === "oauth" && provider.apiKey) {
196
196
  try {
197
- const models = await fetchOpenAICodexModels({
197
+ const descriptors = await fetchOpenAICodexModels({
198
198
  baseURL: provider.baseURL,
199
199
  accessToken: provider.apiKey,
200
200
  });
201
- if (models.length > 0) {
202
- return models.map((id) => ({ id, name: id, providerId: provider.id }));
201
+ const visible = descriptors.filter((d) => d.visibility !== "hide");
202
+ if (visible.length > 0) {
203
+ for (const d of visible) {
204
+ const catalogEntry = getBuiltinModel("openai-codex", d.id);
205
+ registerDynamicModelMetadata({
206
+ id: d.id,
207
+ name: d.displayName || catalogEntry?.name || d.id,
208
+ providerId: "openai-codex",
209
+ reasoningLevels: d.reasoningLevels ?? catalogEntry?.reasoningLevels ?? ["off"],
210
+ contextWindow: d.contextWindow ?? catalogEntry?.contextWindow,
211
+ toolOutputTokenLimit: d.toolOutputTokenLimit ?? catalogEntry?.toolOutputTokenLimit,
212
+ });
213
+ }
214
+ return visible.map((d) => ({
215
+ id: d.id,
216
+ name: d.displayName || d.id,
217
+ providerId: provider.id,
218
+ }));
203
219
  }
204
220
  }
205
221
  catch {
@@ -232,8 +248,8 @@ export function decodeModel(value) {
232
248
  }
233
249
  /** Strip provider prefix for concise display. */
234
250
  export function displayModel(model) {
235
- const { modelId } = decodeModel(model);
236
- return modelId;
251
+ const { providerId, modelId } = decodeModel(model);
252
+ return providerId ? getBuiltinModel(providerId, modelId)?.name ?? modelId : modelId;
237
253
  }
238
254
  /** Normalize user input to provider:model format when possible. */
239
255
  export function normalizeModel(model, defaultProvider = "openai") {
@@ -3,7 +3,9 @@ export { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
3
3
  export interface ProviderRequestConfig {
4
4
  effectiveThinkingLevel: ThinkingLevel;
5
5
  reasoningEffort?: ThinkingLevel;
6
- reasoningContentEcho?: "tool_calls" | "all";
6
+ reasoningContentEcho?: "tool_calls" | "all" | "none";
7
+ parallelToolCalls?: boolean;
8
+ maxTokens?: number;
7
9
  extraBody?: Record<string, unknown>;
8
10
  omitTemperature?: boolean;
9
11
  }
@@ -3,6 +3,13 @@ export { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
3
3
  const MOONSHOT_PROVIDER_IDS = new Set(["moonshot-cn", "moonshot-intl", "kimi-for-coding"]);
4
4
  const KIMI_K25_FAMILY = new Set(["kimi-k2.5", "k2.6-code-preview", "kimi-k2.6"]);
5
5
  const KIMI_THINKING_FAMILY = new Set(["kimi-k2-thinking", "kimi-k2-thinking-turbo"]);
6
+ const KIMI_K26_DEFAULT_MAX_TOKENS = 32768;
7
+ function isFireworksKimi(providerId, modelId) {
8
+ const model = modelId.toLowerCase();
9
+ return providerId === "fireworks" && (model.includes("kimi")
10
+ || model.includes("k2p6")
11
+ || model === "k2.6");
12
+ }
6
13
  export function resolveProviderRequestConfig(providerId, modelId, requestedLevel) {
7
14
  const supportedLevels = getAvailableThinkingLevels(providerId, modelId);
8
15
  const effectiveThinkingLevel = normalizeThinkingLevel(requestedLevel, supportedLevels);
@@ -11,6 +18,14 @@ export function resolveProviderRequestConfig(providerId, modelId, requestedLevel
11
18
  if (providerId === "openai-codex") {
12
19
  return { effectiveThinkingLevel };
13
20
  }
21
+ if (isFireworksKimi(providerId, modelId)) {
22
+ return {
23
+ effectiveThinkingLevel,
24
+ reasoningContentEcho: "none",
25
+ parallelToolCalls: false,
26
+ maxTokens: KIMI_K26_DEFAULT_MAX_TOKENS,
27
+ };
28
+ }
14
29
  if (providerId === "deepseek" && (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro")) {
15
30
  return {
16
31
  effectiveThinkingLevel,
@@ -4,10 +4,13 @@
4
4
  * Works with OpenRouter, OpenAI, DeepSeek, Google, Groq, Together, and local OpenAI-compatible endpoints.
5
5
  */
6
6
  import type { Provider, ProviderMessage, StreamChunk, ThinkingLevel } from "./types.js";
7
- type ReasoningContentEcho = "tool_calls" | "all";
7
+ type ReasoningContentEcho = "tool_calls" | "all" | "none";
8
8
  export type ToolArgsMergeMode = "delta" | "snapshot";
9
9
  export interface TranslateOpenAIStreamOptions {
10
10
  toolArgsMergeMode?: ToolArgsMergeMode;
11
+ reasoningMergeMode?: ToolArgsMergeMode;
12
+ debugProviderId?: string;
13
+ debugModelId?: string;
11
14
  }
12
15
  export declare function toChatCompletionsMessage(message: ProviderMessage, options?: {
13
16
  reasoningContentEcho?: ReasoningContentEcho;
package/dist/provider.js CHANGED
@@ -8,6 +8,7 @@ import { appendFileSync } from "node:fs";
8
8
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
9
9
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
10
10
  import { resolveProviderRequestConfig } from "./provider-transform.js";
11
+ import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
11
12
  // Diagnostic logger for tool-args byte-loss investigation. Activate with
12
13
  // BUBBLE_DEBUG_TOOL_ARGS=/path/to/log.jsonl (any writable path)
13
14
  // Each line is a JSON record describing a transition. When debugging is off,
@@ -101,6 +102,12 @@ export function createProviderInstance(options) {
101
102
  if (requestConfig.extraBody) {
102
103
  Object.assign(body, requestConfig.extraBody);
103
104
  }
105
+ if (tools && tools.length > 0 && requestConfig.parallelToolCalls !== undefined) {
106
+ body.parallel_tool_calls = requestConfig.parallelToolCalls;
107
+ }
108
+ if (requestConfig.maxTokens !== undefined) {
109
+ body.max_tokens = requestConfig.maxTokens;
110
+ }
104
111
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
105
112
  body.reasoning = { enabled: true };
106
113
  }
@@ -109,6 +116,9 @@ export function createProviderInstance(options) {
109
116
  }));
110
117
  yield* translateOpenAIStream(stream, {
111
118
  toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
119
+ reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
120
+ debugProviderId: options.providerId || "",
121
+ debugModelId: chatOptions.model,
112
122
  });
113
123
  yield { type: "done" };
114
124
  }
@@ -126,6 +136,9 @@ export function createProviderInstance(options) {
126
136
  if (requestConfig.extraBody) {
127
137
  Object.assign(body, requestConfig.extraBody);
128
138
  }
139
+ if (requestConfig.maxTokens !== undefined) {
140
+ body.max_tokens = requestConfig.maxTokens;
141
+ }
129
142
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
130
143
  body.reasoning = { enabled: true };
131
144
  }
@@ -188,6 +201,13 @@ function resolveToolArgsMergeMode(providerId, baseURL) {
188
201
  return "snapshot";
189
202
  return "delta";
190
203
  }
204
+ function resolveReasoningMergeMode(providerId, baseURL) {
205
+ const id = providerId.toLowerCase();
206
+ const url = baseURL.toLowerCase();
207
+ if (id === "fireworks" || url.includes("fireworks.ai"))
208
+ return "snapshot";
209
+ return "delta";
210
+ }
191
211
  function extractBalancedJson(s, start) {
192
212
  if (s[start] !== "{")
193
213
  return null;
@@ -232,6 +252,9 @@ export async function* translateOpenAIStream(stream, options = {}) {
232
252
  const toolCalls = new Map();
233
253
  const textFilter = createProviderProtocolArtifactFilter();
234
254
  const toolArgsMergeMode = options.toolArgsMergeMode ?? "delta";
255
+ const reasoningMergeMode = options.reasoningMergeMode ?? "delta";
256
+ let reasoningBuffer = "";
257
+ let rawChunkSeq = 0;
235
258
  // DeepSeek (and some inference re-hosts) sometimes deliver reasoning twice:
236
259
  // once via a dedicated `reasoning_content` / `thinking` field, and again
237
260
  // embedded as `<think>...</think>` inside `delta.content`. Track whether we
@@ -277,8 +300,21 @@ export async function* translateOpenAIStream(stream, options = {}) {
277
300
  }
278
301
  }
279
302
  for await (const chunk of stream) {
303
+ rawChunkSeq += 1;
280
304
  const delta = chunk.choices?.[0]?.delta;
281
305
  const usage = chunk.usage;
306
+ const finishReason = chunk.choices?.[0]?.finish_reason;
307
+ debugReasoningStream({
308
+ stage: "provider_raw",
309
+ providerId: options.debugProviderId,
310
+ modelId: options.debugModelId,
311
+ chunkSeq: rawChunkSeq,
312
+ finishReason,
313
+ content: summarizeDebugText(delta?.content),
314
+ reasoning: summarizeDebugText(delta?.reasoning),
315
+ thinking: summarizeDebugText(delta?.thinking),
316
+ reasoningContent: summarizeDebugText(delta?.reasoning_content),
317
+ });
282
318
  if (usage) {
283
319
  yield {
284
320
  type: "usage",
@@ -294,16 +330,53 @@ export async function* translateOpenAIStream(stream, options = {}) {
294
330
  },
295
331
  };
296
332
  }
297
- const reasoning = delta?.reasoning ?? delta?.thinking ?? delta?.reasoning_content;
333
+ const reasoningField = delta?.reasoning !== undefined
334
+ ? "reasoning"
335
+ : delta?.thinking !== undefined
336
+ ? "thinking"
337
+ : delta?.reasoning_content !== undefined
338
+ ? "reasoning_content"
339
+ : undefined;
340
+ const reasoning = reasoningField ? delta[reasoningField] : undefined;
298
341
  if (reasoning) {
299
342
  hasDedicatedReasoningChannel = true;
300
- yield { type: "reasoning_delta", content: reasoning };
343
+ const merged = mergeStreamingText(reasoningBuffer, reasoning, reasoningMergeMode);
344
+ reasoningBuffer = merged.args;
345
+ debugReasoningStream({
346
+ stage: "provider_emit",
347
+ providerId: options.debugProviderId,
348
+ modelId: options.debugModelId,
349
+ chunkSeq: rawChunkSeq,
350
+ source: reasoningField,
351
+ mergeMode: reasoningMergeMode,
352
+ suppressed: !merged.delta,
353
+ emitted: summarizeDebugText(merged.delta),
354
+ buffer: summarizeDebugText(reasoningBuffer),
355
+ });
356
+ if (merged.delta) {
357
+ yield { type: "reasoning_delta", content: merged.delta };
358
+ }
301
359
  }
302
360
  if (delta?.content) {
303
361
  const thinkMatch = delta.content.match(/<think>([\s\S]*?)<\/think>/);
304
362
  if (thinkMatch) {
305
363
  if (thinkMatch[1] && !hasDedicatedReasoningChannel) {
306
- yield { type: "reasoning_delta", content: thinkMatch[1] };
364
+ const merged = mergeStreamingText(reasoningBuffer, thinkMatch[1], reasoningMergeMode);
365
+ reasoningBuffer = merged.args;
366
+ debugReasoningStream({
367
+ stage: "provider_emit",
368
+ providerId: options.debugProviderId,
369
+ modelId: options.debugModelId,
370
+ chunkSeq: rawChunkSeq,
371
+ source: "content_think",
372
+ mergeMode: reasoningMergeMode,
373
+ suppressed: !merged.delta,
374
+ emitted: summarizeDebugText(merged.delta),
375
+ buffer: summarizeDebugText(reasoningBuffer),
376
+ });
377
+ if (merged.delta) {
378
+ yield { type: "reasoning_delta", content: merged.delta };
379
+ }
307
380
  }
308
381
  const remaining = delta.content.replace(/<think>[\s\S]*?<\/think>/, "");
309
382
  const cleaned = textFilter.push(remaining);
@@ -348,7 +421,6 @@ export async function* translateOpenAIStream(stream, options = {}) {
348
421
  }
349
422
  }
350
423
  }
351
- const finishReason = chunk.choices?.[0]?.finish_reason;
352
424
  if (finishReason === "tool_calls") {
353
425
  yield* flushToolCalls();
354
426
  }
@@ -386,3 +458,16 @@ function mergeToolArgumentDelta(current, incoming, mode) {
386
458
  debugToolArgs({ stage: "merge", branch: mode === "delta" ? "delta-append" : "snapshot-fallback-concat", current, incoming, args: current + incoming, delta: incoming });
387
459
  return { args: current + incoming, delta: incoming };
388
460
  }
461
+ function mergeStreamingText(current, incoming, mode) {
462
+ if (!current)
463
+ return { args: incoming, delta: incoming };
464
+ if (!incoming)
465
+ return { args: current, delta: "" };
466
+ if (mode === "snapshot") {
467
+ if (incoming === current)
468
+ return { args: current, delta: "" };
469
+ if (incoming.startsWith(current))
470
+ return { args: incoming, delta: incoming.slice(current.length) };
471
+ }
472
+ return { args: current + incoming, delta: incoming };
473
+ }
@@ -0,0 +1,7 @@
1
+ export interface DebugTextSummary {
2
+ length: number;
3
+ hash: string;
4
+ preview?: string;
5
+ }
6
+ export declare function summarizeDebugText(value: unknown): DebugTextSummary | undefined;
7
+ export declare function debugReasoningStream(event: Record<string, unknown>): void;
@@ -0,0 +1,30 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ const DEBUG_PATH = process.env.BUBBLE_DEBUG_REASONING_STREAM?.trim();
5
+ const INCLUDE_PREVIEW = process.env.BUBBLE_DEBUG_REASONING_PREVIEW !== "0";
6
+ const PREVIEW_CHARS = 180;
7
+ let sequence = 0;
8
+ export function summarizeDebugText(value) {
9
+ if (!DEBUG_PATH)
10
+ return undefined;
11
+ if (typeof value !== "string" || value.length === 0)
12
+ return undefined;
13
+ const hash = createHash("sha256").update(value).digest("hex").slice(0, 16);
14
+ const summary = { length: value.length, hash };
15
+ if (INCLUDE_PREVIEW) {
16
+ summary.preview = value.replace(/\s+/g, " ").slice(0, PREVIEW_CHARS);
17
+ }
18
+ return summary;
19
+ }
20
+ export function debugReasoningStream(event) {
21
+ if (!DEBUG_PATH)
22
+ return;
23
+ try {
24
+ mkdirSync(dirname(DEBUG_PATH), { recursive: true });
25
+ appendFileSync(DEBUG_PATH, JSON.stringify({ t: Date.now(), seq: ++sequence, ...event }) + "\n", "utf-8");
26
+ }
27
+ catch {
28
+ // Debug logging must never affect an agent run.
29
+ }
30
+ }
@@ -72,6 +72,9 @@ export class SessionLog {
72
72
  getTodos() {
73
73
  for (let i = this.entries.length - 1; i >= 0; i--) {
74
74
  const entry = this.entries[i];
75
+ if (entry.type === "marker" && entry.kind === "conversation_clear") {
76
+ return [];
77
+ }
75
78
  if (entry.type === "todos_snapshot") {
76
79
  return entry.todos.map((todo) => ({ ...todo }));
77
80
  }
@@ -92,20 +95,28 @@ export class SessionLog {
92
95
  toMessages() {
93
96
  const messages = [];
94
97
  let latestSummaryIndex = -1;
98
+ let latestClearIndex = -1;
95
99
  for (let index = this.entries.length - 1; index >= 0; index--) {
96
100
  if (this.entries[index].type === "summary") {
97
101
  latestSummaryIndex = index;
98
102
  break;
99
103
  }
100
104
  }
101
- if (latestSummaryIndex >= 0) {
105
+ for (let index = this.entries.length - 1; index >= 0; index--) {
106
+ const entry = this.entries[index];
107
+ if (entry.type === "marker" && entry.kind === "conversation_clear") {
108
+ latestClearIndex = index;
109
+ break;
110
+ }
111
+ }
112
+ if (latestSummaryIndex > latestClearIndex) {
102
113
  const summary = this.entries[latestSummaryIndex];
103
114
  messages.push({
104
115
  role: "system",
105
116
  content: `Previous conversation summary: ${summary.summary}`,
106
117
  });
107
118
  }
108
- const startIndex = latestSummaryIndex >= 0 ? latestSummaryIndex + 1 : 0;
119
+ const startIndex = Math.max(latestSummaryIndex > latestClearIndex ? latestSummaryIndex + 1 : 0, latestClearIndex + 1);
109
120
  for (let index = startIndex; index < this.entries.length; index++) {
110
121
  const entry = this.entries[index];
111
122
  switch (entry.type) {
@@ -5,7 +5,7 @@ export interface SessionMetadata {
5
5
  reasoningEffort?: ThinkingLevel;
6
6
  cwd?: string;
7
7
  }
8
- export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch";
8
+ export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch" | "conversation_clear";
9
9
  interface BaseSessionLogEntry {
10
10
  id: string;
11
11
  timestamp: number;
@@ -1,4 +1,5 @@
1
1
  import { UserConfig, maskKey } from "../config.js";
2
+ import { formatContextUsage } from "../context/usage.js";
2
3
  import { formatDiagnostics } from "../lsp/index.js";
3
4
  import { normalizeNameForMCP } from "../mcp/name.js";
4
5
  import { parseRule } from "../permissions/rule.js";
@@ -70,6 +71,29 @@ function syncSystemPrompt(ctx, model) {
70
71
  memoryPrompt: buildMemoryPrompt(ctx.cwd),
71
72
  }));
72
73
  }
74
+ function formatMcpContextStatus(ctx) {
75
+ const states = ctx.mcpManager?.getStates() ?? [];
76
+ const lines = ["MCP"];
77
+ if (!ctx.mcpManager || states.length === 0) {
78
+ lines.push("- No MCP servers configured for this session.");
79
+ lines.push("- Context impact: none.");
80
+ return lines.join("\n");
81
+ }
82
+ for (const state of states) {
83
+ if (state.status.kind === "connected") {
84
+ lines.push(`- ${state.name} (${state.scope}): connected · ${state.status.tools.length} deferred tool${state.status.tools.length === 1 ? "" : "s"} · ${state.status.prompts.length} prompt${state.status.prompts.length === 1 ? "" : "s"}`);
85
+ continue;
86
+ }
87
+ if (state.status.kind === "failed") {
88
+ lines.push(`- ${state.name} (${state.scope}): failed · ${state.status.error}`);
89
+ continue;
90
+ }
91
+ lines.push(`- ${state.name} (${state.scope}): ${state.status.kind}`);
92
+ }
93
+ lines.push("- Context impact: MCP tool schemas are deferred. The prompt pays only a small deferred-tool reminder until tool_search unlocks a tool; unlocked MCP schemas then count under Tools.");
94
+ lines.push("- MCP prompts are slash commands; they do not enter context until invoked.");
95
+ return lines.join("\n");
96
+ }
73
97
  function switchToProviderModel(providerId, modelId, ctx, thinkingLevel) {
74
98
  const provider = ctx.registry.getConfigured().find((item) => item.id === providerId);
75
99
  if (!provider?.apiKey) {
@@ -270,6 +294,13 @@ const builtinSlashCommandEntries = [
270
294
  return handleMemoryCommand(args, ctx);
271
295
  },
272
296
  },
297
+ {
298
+ name: "context",
299
+ description: "Show current context window usage and breakdown",
300
+ async handler(args, ctx) {
301
+ return `${formatContextUsage(ctx.agent.getContextUsageSnapshot())}\n\n${formatMcpContextStatus(ctx)}`;
302
+ },
303
+ },
273
304
  {
274
305
  name: "quit",
275
306
  description: "Exit the application",
@@ -277,13 +308,40 @@ const builtinSlashCommandEntries = [
277
308
  ctx.exit();
278
309
  },
279
310
  },
311
+ {
312
+ name: "theme",
313
+ description: "Switch the color theme. Usage: /theme [auto|light|dark]",
314
+ async handler(args, ctx) {
315
+ if (!ctx.setThemeMode || !ctx.getThemeMode || !ctx.getResolvedTheme) {
316
+ return "Theme switching is only available inside the TUI.";
317
+ }
318
+ const arg = args.trim().toLowerCase();
319
+ if (!arg) {
320
+ const order = ["auto", "light", "dark"];
321
+ const current = ctx.getThemeMode();
322
+ const next = order[(order.indexOf(current) + 1) % order.length];
323
+ ctx.setThemeMode(next);
324
+ const resolved = next === "auto" ? ctx.getResolvedTheme() : next;
325
+ return `Theme: ${next}${next === "auto" ? ` (resolved to ${resolved})` : ""}`;
326
+ }
327
+ if (arg !== "auto" && arg !== "light" && arg !== "dark") {
328
+ return "Usage: /theme [auto|light|dark]";
329
+ }
330
+ ctx.setThemeMode(arg);
331
+ const resolved = arg === "auto" ? ctx.getResolvedTheme() : arg;
332
+ return `Theme set to ${arg}${arg === "auto" ? ` (resolved to ${resolved})` : ""}.`;
333
+ },
334
+ },
280
335
  {
281
336
  name: "clear",
282
337
  description: "Clear the current conversation history",
283
338
  async handler(args, ctx) {
339
+ ctx.agent.messages = ctx.agent.messages.filter((m) => m.role === "system" || m.role === "meta");
340
+ ctx.sessionManager?.appendMarker("conversation_clear", "");
341
+ if (ctx.agent.getTodos().length > 0) {
342
+ ctx.agent.setTodos([]);
343
+ }
284
344
  ctx.clearMessages();
285
- ctx.agent.messages = ctx.agent.messages.filter((m) => m.role === "system");
286
- return "Conversation cleared.";
287
345
  },
288
346
  },
289
347
  {
@@ -8,6 +8,7 @@ import type { SettingsManager } from "../permissions/settings.js";
8
8
  import type { McpManager } from "../mcp/manager.js";
9
9
  import type { LspService } from "../lsp/index.js";
10
10
  import type { MemoryScope } from "../memory/index.js";
11
+ import type { ThemeMode } from "../config.js";
11
12
  export interface SlashCommandContext {
12
13
  agent: Agent;
13
14
  addMessage: (role: "user" | "assistant" | "error", content: string) => void;
@@ -27,6 +28,12 @@ export interface SlashCommandContext {
27
28
  runMemoryCompaction?: () => Promise<string>;
28
29
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
29
30
  runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
31
+ /** Get the current theme mode (auto/light/dark) — undefined when running in non-TUI contexts. */
32
+ getThemeMode?: () => ThemeMode;
33
+ /** Get the resolved active theme (always light or dark) — undefined when running in non-TUI contexts. */
34
+ getResolvedTheme?: () => "light" | "dark";
35
+ /** Persist a new theme mode AND apply it to the running TUI. */
36
+ setThemeMode?: (mode: ThemeMode) => void;
30
37
  }
31
38
  /**
32
39
  * Return types for a slash command handler: