@bubblebrain-ai/bubble 0.0.14 → 0.0.16

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 (39) hide show
  1. package/README.md +24 -0
  2. package/dist/agent/discovery-barrier.d.ts +21 -0
  3. package/dist/agent/discovery-barrier.js +173 -0
  4. package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
  5. package/dist/agent/internal-reminder-sanitizer.js +171 -0
  6. package/dist/agent/task-classifier.js +23 -5
  7. package/dist/agent.js +119 -26
  8. package/dist/cli.d.ts +3 -1
  9. package/dist/cli.js +12 -0
  10. package/dist/context/projector.js +4 -3
  11. package/dist/main.js +13 -0
  12. package/dist/model-catalog.js +6 -0
  13. package/dist/model-pricing.d.ts +3 -2
  14. package/dist/model-pricing.js +8 -0
  15. package/dist/network/chatgpt-transport.d.ts +16 -0
  16. package/dist/network/chatgpt-transport.js +240 -0
  17. package/dist/oauth/openai-codex.d.ts +7 -2
  18. package/dist/oauth/openai-codex.js +7 -4
  19. package/dist/orchestrator/default-hooks.js +13 -2
  20. package/dist/orchestrator/hooks.d.ts +2 -0
  21. package/dist/provider-openai-codex.d.ts +3 -0
  22. package/dist/provider-openai-codex.js +11 -2
  23. package/dist/provider-transform.js +9 -0
  24. package/dist/reasoning-debug.js +4 -1
  25. package/dist/session-log.js +4 -1
  26. package/dist/stats/usage.d.ts +4 -0
  27. package/dist/stats/usage.js +48 -11
  28. package/dist/tools/glob.js +3 -0
  29. package/dist/tools/grep.js +7 -0
  30. package/dist/tui/run.d.ts +2 -0
  31. package/dist/tui/run.js +104 -42
  32. package/dist/tui-ink/app.js +3 -0
  33. package/dist/tui-ink/message-list.js +6 -3
  34. package/dist/tui-opentui/app.js +3 -0
  35. package/dist/tui-opentui/message-list.js +6 -3
  36. package/dist/types.d.ts +1 -1
  37. package/dist/update/index.d.ts +46 -0
  38. package/dist/update/index.js +240 -0
  39. package/package.json +2 -1
@@ -1,3 +1,4 @@
1
+ import { sanitizeInternalReminderBlocks } from "./agent/internal-reminder-sanitizer.js";
1
2
  export class SessionLog {
2
3
  entries = [];
3
4
  load(lines) {
@@ -190,7 +191,9 @@ function normalizeMessageToEntries(message, id, timestamp) {
190
191
  message: {
191
192
  role: "assistant",
192
193
  content: message.content,
193
- reasoning: message.reasoning,
194
+ reasoning: message.reasoning !== undefined
195
+ ? sanitizeInternalReminderBlocks(message.reasoning)
196
+ : undefined,
194
197
  model: message.model,
195
198
  providerId: message.providerId,
196
199
  modelId: message.modelId,
@@ -1,3 +1,4 @@
1
+ import type { PricingCurrency } from "../model-pricing.js";
1
2
  export type StatsRange = "7d" | "30d";
2
3
  export interface DailyUsage {
3
4
  date: string;
@@ -22,6 +23,7 @@ export interface ModelUsageStats {
22
23
  reasoningTokens: number;
23
24
  totalTokens: number;
24
25
  cost?: number;
26
+ costCurrency?: PricingCurrency;
25
27
  }
26
28
  export interface UsageStats {
27
29
  range: StatsRange;
@@ -32,7 +34,9 @@ export interface UsageStats {
32
34
  heatmap: HeatmapColumn[];
33
35
  models: ModelUsageStats[];
34
36
  totalTokens: number;
37
+ trackedCosts?: Partial<Record<PricingCurrency, number>>;
35
38
  trackedCost?: number;
39
+ trackedCostCurrency?: PricingCurrency;
36
40
  activeDays: number;
37
41
  sessionsScanned: number;
38
42
  sessionsWithoutTokenData: number;
@@ -61,11 +61,15 @@ export function formatCompactNumber(value) {
61
61
  return String(Math.round(value));
62
62
  }
63
63
  export function formatCurrency(value) {
64
- if (value >= 1)
65
- return `$${value.toFixed(2)}`;
66
- if (value >= 0.01)
67
- return `$${value.toFixed(3)}`;
68
- return `$${value.toFixed(4)}`;
64
+ return formatCurrencyFor(value, "USD");
65
+ }
66
+ function formatCurrencyFor(value, currency) {
67
+ const amount = value >= 1
68
+ ? value.toFixed(2)
69
+ : value >= 0.01
70
+ ? value.toFixed(3)
71
+ : value.toFixed(4);
72
+ return currency === "USD" ? `$${amount}` : `CNY ${amount}`;
69
73
  }
70
74
  function createAccumulator(range, days, now) {
71
75
  const end = startOfLocalDay(now);
@@ -146,7 +150,11 @@ function finalizeAccumulator(accumulator) {
146
150
  .filter((model) => model.totalTokens > 0)
147
151
  .sort((a, b) => b.totalTokens - a.totalTokens);
148
152
  const totalTokens = models.reduce((sum, model) => sum + model.totalTokens, 0);
149
- const trackedCost = models.reduce((sum, model) => sum + (model.cost ?? 0), 0);
153
+ const trackedCosts = aggregateCosts(models);
154
+ const trackedCostEntries = trackedCosts
155
+ ? Object.entries(trackedCosts)
156
+ : [];
157
+ const trackedCostEntry = trackedCostEntries.length === 1 ? trackedCostEntries[0] : undefined;
150
158
  return {
151
159
  range: accumulator.range,
152
160
  days: accumulator.days,
@@ -156,7 +164,9 @@ function finalizeAccumulator(accumulator) {
156
164
  heatmap: buildHeatmap(daily),
157
165
  models,
158
166
  totalTokens,
159
- trackedCost: trackedCost > 0 ? trackedCost : undefined,
167
+ trackedCosts,
168
+ trackedCost: trackedCostEntry ? trackedCostEntry[1] : undefined,
169
+ trackedCostCurrency: trackedCostEntry ? trackedCostEntry[0] : undefined,
160
170
  activeDays: daily.filter((day) => day.active).length,
161
171
  sessionsScanned: accumulator.sessionsScanned,
162
172
  sessionsWithoutTokenData: accumulator.sessionsWithoutTokenData,
@@ -189,8 +199,10 @@ function addModelUsage(accumulator, model, message, usage) {
189
199
  existing.totalTokens += tokenTotal(usage);
190
200
  if (providerId && modelId) {
191
201
  const cost = calculateUsageCost(providerId, modelId, usage);
192
- if (cost)
202
+ if (cost) {
193
203
  existing.cost = (existing.cost ?? 0) + cost.cost;
204
+ existing.costCurrency = cost.currency;
205
+ }
194
206
  }
195
207
  accumulator.modelUsage.set(key, existing);
196
208
  }
@@ -254,7 +266,9 @@ function formatModelUsageLines(stats, width) {
254
266
  const percentText = `${Math.round(percent * 100)}%`.padStart(4, " ");
255
267
  const tokenText = formatCompactNumber(model.totalTokens).padStart(6, " ");
256
268
  const turnsText = `${model.turns}t`.padStart(4, " ");
257
- const costText = showCost ? ` ${(model.cost !== undefined ? formatCurrency(model.cost) : "").padStart(7, " ")}` : "";
269
+ const costText = showCost
270
+ ? ` ${(model.cost !== undefined ? formatCurrencyFor(model.cost, model.costCurrency ?? "USD") : "").padStart(7, " ")}`
271
+ : "";
258
272
  return ` ${truncate(model.displayName, labelWidth).padEnd(labelWidth, " ")} ${bar} ${percentText} ${tokenText} ${turnsText}${costText}`.trimEnd();
259
273
  });
260
274
  if (stats.models.length > MAX_MODEL_ROWS) {
@@ -270,14 +284,37 @@ function formatSummaryLines(stats, width) {
270
284
  if (favorite) {
271
285
  lines.push(` Favorite model ${truncate(favorite, Math.max(12, width - 17))}`);
272
286
  }
273
- if (stats.trackedCost !== undefined)
274
- lines.push(` Tracked cost ${formatCurrency(stats.trackedCost)}`);
287
+ const trackedCostText = formatTrackedCosts(stats);
288
+ if (trackedCostText)
289
+ lines.push(` Tracked cost ${trackedCostText}`);
275
290
  lines.push(` Sessions scanned ${stats.sessionsScanned}`);
276
291
  if (stats.sessionsWithoutTokenData > 0) {
277
292
  lines.push(` Sessions without token data ${stats.sessionsWithoutTokenData}`);
278
293
  }
279
294
  return lines;
280
295
  }
296
+ function aggregateCosts(models) {
297
+ const totals = {};
298
+ for (const model of models) {
299
+ if (model.cost === undefined)
300
+ continue;
301
+ const currency = model.costCurrency ?? "USD";
302
+ totals[currency] = (totals[currency] ?? 0) + model.cost;
303
+ }
304
+ return Object.keys(totals).length > 0 ? totals : undefined;
305
+ }
306
+ function formatTrackedCosts(stats) {
307
+ if (stats.trackedCosts) {
308
+ const parts = Object.entries(stats.trackedCosts)
309
+ .filter(([, value]) => value > 0)
310
+ .map(([currency, value]) => formatCurrencyFor(value, currency));
311
+ return parts.length > 0 ? parts.join(" + ") : undefined;
312
+ }
313
+ if (stats.trackedCost !== undefined) {
314
+ return formatCurrencyFor(stats.trackedCost, stats.trackedCostCurrency ?? "USD");
315
+ }
316
+ return undefined;
317
+ }
281
318
  function heatmapCell(day, maxTokens) {
282
319
  if (!day)
283
320
  return " ";
@@ -65,6 +65,7 @@ export function createGlobTool(cwd) {
65
65
  }
66
66
  files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
67
67
  const matches = files.slice(0, MAX_RESULTS).map((item) => item.path);
68
+ const absoluteMatches = matches.map((item) => resolve(root, item));
68
69
  const wasTruncated = truncated.value || files.length > MAX_RESULTS;
69
70
  if (matches.length === 0) {
70
71
  return {
@@ -78,6 +79,7 @@ export function createGlobTool(cwd) {
78
79
  truncated: false,
79
80
  searchSignature: `glob:${root}:${pattern}`,
80
81
  searchFamily: `glob:${pattern}`,
82
+ paths: [],
81
83
  },
82
84
  };
83
85
  }
@@ -92,6 +94,7 @@ export function createGlobTool(cwd) {
92
94
  truncated: wasTruncated,
93
95
  searchSignature: `glob:${root}:${pattern}`,
94
96
  searchFamily: `glob:${pattern}`,
97
+ paths: absoluteMatches,
95
98
  },
96
99
  };
97
100
  },
@@ -2,6 +2,7 @@
2
2
  * Grep tool - search file contents using ripgrep.
3
3
  */
4
4
  import { execFile } from "node:child_process";
5
+ import { resolve as resolvePath } from "node:path";
5
6
  import { isSensitivePath } from "./sensitive-paths.js";
6
7
  import { analyzeToolIntent } from "../agent/tool-intent.js";
7
8
  import { resolveToolPath } from "./path-utils.js";
@@ -57,6 +58,7 @@ export function createGrepTool(cwd) {
57
58
  // rg returns exit code 1 when no matches found, which is not an error for us
58
59
  const lines = stdout.split("\n").filter((l) => l.trim() !== "");
59
60
  const matches = [];
61
+ const matchedPaths = new Set();
60
62
  for (const line of lines) {
61
63
  try {
62
64
  const obj = JSON.parse(line);
@@ -64,6 +66,9 @@ export function createGrepTool(cwd) {
64
66
  const path = obj.data.path.text;
65
67
  const lineNum = obj.data.line_number;
66
68
  const text = obj.data.lines.text?.trim() ?? "";
69
+ if (typeof path === "string" && path.trim()) {
70
+ matchedPaths.add(resolvePath(cwd, path));
71
+ }
67
72
  matches.push(`${path}:${lineNum}: ${text}`);
68
73
  }
69
74
  }
@@ -83,6 +88,7 @@ export function createGrepTool(cwd) {
83
88
  truncated: false,
84
89
  searchSignature: intent.search?.signature,
85
90
  searchFamily: intent.search?.familyKey,
91
+ paths: [],
86
92
  },
87
93
  });
88
94
  return;
@@ -103,6 +109,7 @@ export function createGrepTool(cwd) {
103
109
  truncated,
104
110
  searchSignature: intent.search?.signature,
105
111
  searchFamily: intent.search?.familyKey,
112
+ paths: [...matchedPaths],
106
113
  },
107
114
  });
108
115
  });
package/dist/tui/run.d.ts CHANGED
@@ -41,5 +41,7 @@ export interface RunTuiOptions {
41
41
  runMemoryCompaction?: () => Promise<string>;
42
42
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
43
43
  runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
44
+ /** One-line "update available" notice shown on the home screen, if any. */
45
+ updateNotice?: string;
44
46
  }
45
47
  export declare function runTui(agent: Agent, args: CliArgs, options?: RunTuiOptions): Promise<void>;
package/dist/tui/run.js CHANGED
@@ -9,10 +9,13 @@ import { homedir } from "node:os";
9
9
  import { AgentAbortError } from "../agent.js";
10
10
  import { AgentRunInputQueue } from "../agent/input-controller.js";
11
11
  import { debugReasoningStream, summarizeDebugText } from "../reasoning-debug.js";
12
+ import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
13
+ import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
12
14
  import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceValue, traceEvent, } from "../debug-trace.js";
13
15
  import { BUILTIN_PROVIDERS, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
14
16
  import { calculateUsageCost } from "../model-pricing.js";
15
17
  import { getAvailableThinkingLevels } from "../provider-transform.js";
18
+ import { getCurrentVersion } from "../update/index.js";
16
19
  import { collectUsageStatsBundle, formatStatsPanelBody } from "../stats/usage.js";
17
20
  import { parseSkillInvocation } from "../skills/invocation.js";
18
21
  import { registry as slashRegistry } from "../slash-commands/index.js";
@@ -59,6 +62,7 @@ const PROVIDER_PRIORITY = new Map([
59
62
  ["zai", 5],
60
63
  ["zai-coding-plan", 6],
61
64
  ["kimi-for-coding", 7],
65
+ ["stepfun", 8],
62
66
  ]);
63
67
  const DEFAULT_THEME = {
64
68
  primary: "#fab283",
@@ -181,7 +185,7 @@ const PROMPT_SCANNER_IDLE_FRAMES = [" "];
181
185
  const PROMPT_SCANNER_INTERVAL_MS = 80;
182
186
  const SESSION_SIDEBAR_WIDTH = 42;
183
187
  const SESSION_SIDEBAR_AUTO_WIDTH = 120;
184
- const PROVIDER_DIALOG_ROWS = 11;
188
+ const PROVIDER_DIALOG_ROWS = 13;
185
189
  const QUESTION_MAX_TABS = 4;
186
190
  const QUESTION_MAX_OPTIONS = 10;
187
191
  const QUESTION_MAX_CONFIRM_ROWS = 3;
@@ -1104,7 +1108,7 @@ function OpenTuiApp(props) {
1104
1108
  if (!safeSetText(ref, promptModeBadge()))
1105
1109
  promptModeLabels.delete(ref);
1106
1110
  };
1107
- const promptModelTitle = () => displayModel(props.agent.model) || "no model";
1111
+ const promptModelTitle = () => displayModelWithThinking(props.agent.model, props.agent.thinking) || "no model";
1108
1112
  const syncModelChrome = () => {
1109
1113
  if (uiDisposed)
1110
1114
  return;
@@ -3046,6 +3050,31 @@ function OpenTuiApp(props) {
3046
3050
  // Keep the already-rendered local catalog when remote model discovery fails.
3047
3051
  }
3048
3052
  }
3053
+ function providerDialogMatchScore(item, query) {
3054
+ const label = (item.label || "").toLowerCase();
3055
+ const value = (item.value || "").toLowerCase();
3056
+ const haystack = [
3057
+ item.label,
3058
+ item.detail,
3059
+ item.value,
3060
+ item.category,
3061
+ item.footer,
3062
+ ].filter(Boolean).join(" ").toLowerCase();
3063
+ if (label.startsWith(query))
3064
+ return 100;
3065
+ if (label.includes(query))
3066
+ return 80;
3067
+ if (value.includes(query))
3068
+ return 60;
3069
+ if (haystack.includes(query))
3070
+ return 40;
3071
+ // Fuzzy (subsequence) match is a last resort, and only against label+value
3072
+ // so long provider descriptions (e.g. "platform.moonshot.cn") don't produce
3073
+ // spurious hits like "gpt" matching "kimi-k2-thinking".
3074
+ if (fuzzyMatch(`${label} ${value}`, query))
3075
+ return 20;
3076
+ return 0;
3077
+ }
3049
3078
  function providerDialogFilteredItems(state = providerDialog) {
3050
3079
  if (!state || state.step === "key")
3051
3080
  return [];
@@ -3053,16 +3082,12 @@ function OpenTuiApp(props) {
3053
3082
  const query = state.query.trim().toLowerCase();
3054
3083
  if (!query)
3055
3084
  return items;
3056
- return items.filter((item) => {
3057
- const haystack = [
3058
- item.label,
3059
- item.detail,
3060
- item.value,
3061
- item.category,
3062
- item.footer,
3063
- ].filter(Boolean).join(" ").toLowerCase();
3064
- return haystack.includes(query) || fuzzyMatch(haystack, query);
3065
- });
3085
+ const scored = items
3086
+ .map((item, order) => ({ item, order, score: providerDialogMatchScore(item, query) }))
3087
+ .filter((entry) => entry.score > 0);
3088
+ // Stable sort by score desc, preserving original catalog order within a tier.
3089
+ scored.sort((a, b) => b.score - a.score || a.order - b.order);
3090
+ return scored.map((entry) => entry.item);
3066
3091
  }
3067
3092
  function providerDialogVisibleRows(state = providerDialog) {
3068
3093
  if (!state)
@@ -3103,7 +3128,7 @@ function OpenTuiApp(props) {
3103
3128
  providerDialogRoot.requestRender();
3104
3129
  return;
3105
3130
  }
3106
- const width = Math.max(48, Math.min(60, dimensions().width - 2));
3131
+ const width = Math.max(56, Math.min(76, dimensions().width - 4));
3107
3132
  const height = PROVIDER_DIALOG_ROWS + 7;
3108
3133
  providerDialogRoot.visible = true;
3109
3134
  providerDialogRoot.width = dimensions().width;
@@ -5053,6 +5078,7 @@ function OpenTuiApp(props) {
5053
5078
  "zhipuai-coding-plan": "Coding Plan",
5054
5079
  "zai-coding-plan": "Coding Plan",
5055
5080
  "kimi-for-coding": "Coding Plan",
5081
+ stepfun: "Step Plan API key",
5056
5082
  local: "OpenAI-compatible local endpoint",
5057
5083
  };
5058
5084
  return descriptions[providerId] ?? "API key";
@@ -5494,6 +5520,10 @@ function OpenTuiApp(props) {
5494
5520
  paddingRight: 2,
5495
5521
  }, [
5496
5522
  h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...logoLines.map((line) => renderHomeLogoLine(line))),
5523
+ h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center", paddingTop: 1 }, h("text", { fg: theme.textMuted, content: `v${getCurrentVersion()}` })),
5524
+ ...(props.options.updateNotice
5525
+ ? [h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, h("text", { fg: theme.accent, content: props.options.updateNotice }))]
5526
+ : []),
5497
5527
  h("box", { height: 1, minHeight: 0, flexShrink: 1 }),
5498
5528
  h("box", {
5499
5529
  ref: (ref) => {
@@ -5996,7 +6026,8 @@ function OpenTuiApp(props) {
5996
6026
  width: "100%",
5997
6027
  value: "",
5998
6028
  placeholder: "",
5999
- fg: theme.text,
6029
+ textColor: theme.text,
6030
+ focusedTextColor: theme.text,
6000
6031
  backgroundColor: theme.backgroundElement,
6001
6032
  focusedBackgroundColor: theme.backgroundElement,
6002
6033
  cursorColor: theme.primary,
@@ -6141,7 +6172,7 @@ function OpenTuiApp(props) {
6141
6172
  },
6142
6173
  visible: false,
6143
6174
  position: "absolute",
6144
- width: 60,
6175
+ width: 76,
6145
6176
  height: PROVIDER_DIALOG_ROWS + 7,
6146
6177
  backgroundColor: theme.backgroundPanel,
6147
6178
  flexDirection: "column",
@@ -6178,7 +6209,8 @@ function OpenTuiApp(props) {
6178
6209
  width: "100%",
6179
6210
  value: "",
6180
6211
  placeholder: "Search",
6181
- fg: theme.textMuted,
6212
+ textColor: theme.text,
6213
+ focusedTextColor: theme.text,
6182
6214
  backgroundColor: theme.backgroundPanel,
6183
6215
  focusedBackgroundColor: theme.backgroundPanel,
6184
6216
  cursorColor: theme.primary,
@@ -6191,15 +6223,11 @@ function OpenTuiApp(props) {
6191
6223
  providerDialog = { ...state, apiKey: value, error: undefined };
6192
6224
  }
6193
6225
  else {
6226
+ const query = value.trim().toLowerCase();
6194
6227
  const items = providerDialogItemsFor(state.step, state.providerId).filter((item) => {
6195
- const query = value.trim().toLowerCase();
6196
6228
  if (!query)
6197
6229
  return true;
6198
- const haystack = [item.label, item.detail, item.value, item.category, item.footer]
6199
- .filter(Boolean)
6200
- .join(" ")
6201
- .toLowerCase();
6202
- return haystack.includes(query) || fuzzyMatch(haystack, query);
6230
+ return providerDialogMatchScore(item, query) > 0;
6203
6231
  });
6204
6232
  providerDialog = {
6205
6233
  ...state,
@@ -6557,7 +6585,7 @@ function OpenTuiApp(props) {
6557
6585
  completionTokens: usage.completionTokens,
6558
6586
  reasoningTokens: usage.reasoningTokens,
6559
6587
  turns: usage.turns,
6560
- costText: cost ? `${formatCurrency(cost.cost)} spent${cost.estimated ? " est." : ""}` : "cost unavailable",
6588
+ costText: cost ? `${formatCurrency(cost.cost, cost.currency)} spent${cost.estimated ? " est." : ""}` : "cost unavailable",
6561
6589
  };
6562
6590
  }
6563
6591
  function sidebarMcpStates() {
@@ -6926,12 +6954,14 @@ function renderUserMessage(message, index) {
6926
6954
  }, h("box", { paddingTop: 1, paddingBottom: 1, paddingLeft: 2, backgroundColor: theme.backgroundPanel, flexShrink: 0, flexDirection: "column" }, ...userChildren));
6927
6955
  }
6928
6956
  function renderAssistantMessage(message, syntaxStyle, subtleSyntaxStyle, showThinking = true, verboseTrace = false, width = 80) {
6957
+ const visibleReasoning = showThinking
6958
+ ? sanitizeInternalReminderBlocks(message.reasoning ?? "").trim()
6959
+ : "";
6929
6960
  const modelSwitch = parseModelSwitchMessage(message.content);
6930
- if (modelSwitch && !message.reasoning?.trim() && !(message.toolCalls?.length)) {
6961
+ if (modelSwitch && !visibleReasoning && !(message.toolCalls?.length)) {
6931
6962
  return renderModelSwitchMessage(modelSwitch);
6932
6963
  }
6933
6964
  const children = [];
6934
- const visibleReasoning = showThinking ? message.reasoning?.trim() : "";
6935
6965
  const parts = message.parts ?? [];
6936
6966
  const hasParts = parts.length > 0;
6937
6967
  if (message.status && !visibleReasoning && !message.content.trim() && !(message.toolCalls?.length) && !hasParts) {
@@ -7054,7 +7084,7 @@ function renderMarkdownContent(content, syntaxStyle, options) {
7054
7084
  bg: theme.background,
7055
7085
  width: "100%",
7056
7086
  tableOptions: {
7057
- widthMode: "full",
7087
+ widthMode: "content",
7058
7088
  columnFitter: "balanced",
7059
7089
  wrapMode: "word",
7060
7090
  cellPadding: 1,
@@ -7274,7 +7304,13 @@ function syncMarkdownRenderable(markdown, content, streaming) {
7274
7304
  return;
7275
7305
  markdown.content = content;
7276
7306
  markdown.streaming = streaming;
7277
- markdown.clearCache();
7307
+ // While streaming, let OpenTUI's incremental markdown/code-block rendering do
7308
+ // its job — clearing the parse cache every delta forces the (syntax-
7309
+ // highlighted) code blocks to be rebuilt and re-highlighted on every token,
7310
+ // which is the source of the visible flicker on streamed code blocks. Clear
7311
+ // the cache only once streaming ends, to fully reparse the finalized content.
7312
+ if (!streaming)
7313
+ markdown.clearCache();
7278
7314
  }
7279
7315
  function updateAssistantPartEntries(entry, parts, options, streaming) {
7280
7316
  const partsBox = entry.refs.partsBox;
@@ -7613,7 +7649,7 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
7613
7649
  width: "100%",
7614
7650
  flexShrink: 0,
7615
7651
  tableOptions: {
7616
- widthMode: "full",
7652
+ widthMode: "content",
7617
7653
  columnFitter: "balanced",
7618
7654
  wrapMode: "word",
7619
7655
  cellPadding: 1,
@@ -8293,7 +8329,7 @@ function renderTool(tool, syntaxStyle, width = 80) {
8293
8329
  const color = toolColor(tool);
8294
8330
  const diff = extractToolDiff(tool);
8295
8331
  if (diff && !tool.resultCollapsed && !tool.isError && (tool.name === "edit" || tool.name === "apply_patch")) {
8296
- return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
8332
+ return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0, backgroundColor: theme.diffContextBg }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
8297
8333
  }
8298
8334
  if (!tool.resultCollapsed && isWritePreviewTool(tool)) {
8299
8335
  const hasContent = typeof tool.args.content === "string";
@@ -8401,10 +8437,20 @@ function pickerTitle(kind, providerId) {
8401
8437
  }
8402
8438
  }
8403
8439
  function getModelPickerReasoningLevels(providerId, modelId) {
8404
- if (providerId !== "deepseek" || (modelId !== "deepseek-v4-flash" && modelId !== "deepseek-v4-pro")) {
8440
+ // Only expand into one picker row per effort for models that genuinely have a
8441
+ // reasoning-effort spectrum: OpenAI's reasoning models (codex gpt-5.x:
8442
+ // off/minimal/low/medium/high/xhigh), DeepSeek's v4 models, and StepFun
8443
+ // Step Plan models. Other providers
8444
+ // (e.g. GLM, Moonshot/Kimi) only have a thinking on/off toggle, not an effort
8445
+ // control, so they stay as a single row.
8446
+ const isOpenAIReasoning = providerId === "openai" || providerId === "openai-codex";
8447
+ const isDeepseekReasoning = providerId === "deepseek" && (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro");
8448
+ const isStepFunReasoning = providerId === "stepfun";
8449
+ if (!isOpenAIReasoning && !isDeepseekReasoning && !isStepFunReasoning)
8405
8450
  return [];
8406
- }
8407
- return getAvailableThinkingLevels(providerId, modelId);
8451
+ const levels = getAvailableThinkingLevels(providerId, modelId);
8452
+ // gpt-4o and friends report only ["off"] — keep those as a single row too.
8453
+ return levels.length > 1 ? levels : [];
8408
8454
  }
8409
8455
  function displayModelWithThinking(model, thinkingLevel) {
8410
8456
  if (!model)
@@ -8412,7 +8458,11 @@ function displayModelWithThinking(model, thinkingLevel) {
8412
8458
  const { providerId, modelId } = decodeModel(model);
8413
8459
  if (!providerId)
8414
8460
  return displayModel(model);
8415
- const levels = getAvailableThinkingLevels(providerId, modelId);
8461
+ // Use the same scoping as the picker: only models with a real reasoning-effort
8462
+ // spectrum (OpenAI codex gpt-5.x, deepseek v4, StepFun Step Plan) get the
8463
+ // "(level)" suffix. The on/off thinking toggle on GLM / Moonshot(Kimi) is
8464
+ // not an effort control.
8465
+ const levels = getModelPickerReasoningLevels(providerId, modelId);
8416
8466
  if (levels.length > 1 && thinkingLevel !== "off") {
8417
8467
  return `${displayModel(model)} (${thinkingLevel})`;
8418
8468
  }
@@ -8608,6 +8658,8 @@ function reconstructDisplayMessages(agentMessages) {
8608
8658
  }
8609
8659
  catch { }
8610
8660
  const toolResult = agentMessages.find((candidate) => candidate.role === "tool" && candidate.toolCallId === tc.id);
8661
+ if (isHiddenToolMetadata(toolResult ? toolResult.metadata : undefined))
8662
+ continue;
8611
8663
  toolCalls.push({
8612
8664
  id: tc.id,
8613
8665
  name: tc.name,
@@ -9066,11 +9118,18 @@ function toolPath(tool) {
9066
9118
  ?? (Array.isArray(tool.metadata?.paths) ? tool.metadata.paths[0] : undefined);
9067
9119
  return typeof value === "string" ? value : undefined;
9068
9120
  }
9121
+ // Strip only leading/trailing newlines — NOT a full .trim(). A blank context
9122
+ // line in a unified diff is a single space (" "); plain .trim() would delete a
9123
+ // trailing blank context line, leaving the hunk body shorter than its @@ header
9124
+ // count and breaking the diff parser ("Added line count did not match").
9125
+ function stripDiffEdgeNewlines(diff) {
9126
+ return diff.replace(/^\n+/, "").replace(/\n+$/, "");
9127
+ }
9069
9128
  function extractToolDiff(tool) {
9070
9129
  if (tool.resultCollapsed)
9071
9130
  return undefined;
9072
9131
  if (typeof tool.metadata?.diff === "string" && tool.metadata.diff.trim().length > 0) {
9073
- return tool.metadata.diff.trim();
9132
+ return stripDiffEdgeNewlines(tool.metadata.diff);
9074
9133
  }
9075
9134
  if (!tool.result)
9076
9135
  return undefined;
@@ -9086,10 +9145,14 @@ function extractToolDiff(tool) {
9086
9145
  const rawDiff = tool.result.slice(index + marker.length);
9087
9146
  const diagnosticsIndex = rawDiff.search(/\n\nLSP diagnostics in /);
9088
9147
  const diff = diagnosticsIndex === -1 ? rawDiff : rawDiff.slice(0, diagnosticsIndex);
9089
- return diff.trim().length > 0 ? diff : undefined;
9148
+ return diff.trim().length > 0 ? stripDiffEdgeNewlines(diff) : undefined;
9090
9149
  }
9091
- function diffViewMode(width = 80) {
9092
- return width > 120 ? "split" : "unified";
9150
+ function diffViewMode(_width = 80) {
9151
+ // Always unified: split view pads the shorter side with empty filler rows that
9152
+ // OpenTUI's DiffRenderable leaves uncolored, which shows up as bright white
9153
+ // blocks in light mode. Unified view has no filler rows — every line is
9154
+ // add/remove/context and gets a background — so the edit area stays uniform.
9155
+ return "unified";
9093
9156
  }
9094
9157
  function filetype(filePath) {
9095
9158
  if (!filePath)
@@ -9337,12 +9400,11 @@ function formatCompactNumber(value) {
9337
9400
  return `${(value / 1_000).toFixed(1)}K`;
9338
9401
  return String(value);
9339
9402
  }
9340
- function formatCurrency(value) {
9403
+ function formatCurrency(value, currency = "USD") {
9341
9404
  if (value < 0.0001)
9342
- return "$0.0000";
9343
- if (value < 1)
9344
- return `$${value.toFixed(4)}`;
9345
- return `$${value.toFixed(2)}`;
9405
+ return currency === "USD" ? "$0.0000" : "CNY 0.0000";
9406
+ const amount = value < 1 ? value.toFixed(4) : value.toFixed(2);
9407
+ return currency === "USD" ? `$${amount}` : `CNY ${amount}`;
9346
9408
  }
9347
9409
  function sidebarStatusColor(kind) {
9348
9410
  if (kind === "connected")
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useApp, useInput } from "ink";
4
4
  import { AgentAbortError } from "../agent.js";
5
+ import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
5
6
  import { registry as slashRegistry } from "../slash-commands/index.js";
6
7
  import { UserConfig, maskKey } from "../config.js";
7
8
  import { createPastedContentMarker, InputBox, isCtrlCInput, shouldCollapsePastedContent, } from "./input-box.js";
@@ -82,6 +83,8 @@ function reconstructDisplayMessages(agentMessages) {
82
83
  args = {};
83
84
  }
84
85
  const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
86
+ if (isHiddenToolMetadata(toolResult ? toolResult.metadata : undefined))
87
+ continue;
85
88
  toolCalls.push({
86
89
  id: tc.id,
87
90
  name: tc.name,
@@ -7,6 +7,7 @@ import { MarkdownContent, StreamingMarkdown } from "./markdown.js";
7
7
  import { buildTraceGroups, formatTracePath, traceGroupLabel } from "./trace-groups.js";
8
8
  import { EDIT_COLLAPSED_DIFF_LINES, formatEditSuccessSummary, getEditDiffDetails } from "./edit-diff.js";
9
9
  import { formatSubagentRoute } from "../agent/subagent-route-format.js";
10
+ import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
10
11
  export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, verboseTrace, pendingApproval, nowTick, welcomeBanner, }) {
11
12
  const hasStreaming = !!(streamingContent ||
12
13
  streamingReasoning ||
@@ -44,22 +45,24 @@ function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, n
44
45
  if (message.syntheticKind === "ui_compact_summary") {
45
46
  return _jsx(CompactionSummaryBlock, { message: message });
46
47
  }
48
+ const visibleReasoning = sanitizeInternalReminderBlocks(message.reasoning ?? "").trim();
47
49
  const hasVisibleAssistantContent = !!message.content ||
48
50
  (message.toolCalls?.length ?? 0) > 0 ||
49
51
  (message.parts?.length ?? 0) > 0 ||
50
- (!!message.reasoning && verboseTrace);
52
+ (!!visibleReasoning && verboseTrace);
51
53
  if (!hasVisibleAssistantContent)
52
54
  return null;
53
- return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [message.reasoning && verboseTrace && _jsx(ReasoningTraceBlock, { reasoning: message.reasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), message.content && _jsx(MarkdownContent, { content: message.content })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
55
+ return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && verboseTrace && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), message.content && _jsx(MarkdownContent, { content: message.content })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
54
56
  }
55
57
  function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, verboseTrace, pendingApproval, nowTick, }) {
56
58
  const deferredContent = React.useDeferredValue(content);
57
59
  const deferredReasoning = React.useDeferredValue(reasoning);
58
60
  const deferredParts = React.useDeferredValue(parts);
61
+ const visibleReasoning = sanitizeInternalReminderBlocks(deferredReasoning).trim();
59
62
  const visibleParts = deferredParts.length > 0
60
63
  ? deferredParts
61
64
  : fallbackStreamingParts(deferredContent, tools);
62
- return (_jsxs(Box, { flexDirection: "column", children: [deferredReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: deferredReasoning }) })), visibleParts.length > 0 && (
65
+ return (_jsxs(Box, { flexDirection: "column", children: [visibleReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }) })), visibleParts.length > 0 && (
63
66
  // marginTop intentionally 0: this Box only mounts on the first non-empty
64
67
  // streaming frame, so a marginTop=1 here would visibly insert a blank
65
68
  // line under the user message right at that moment (the "spinner sits
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useKeyboard, useRenderer } from "@opentui/react";
5
5
  import { AgentAbortError } from "../agent.js";
6
+ import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
6
7
  import { registry as slashRegistry } from "../slash-commands/index.js";
7
8
  import { UserConfig, maskKey } from "../config.js";
8
9
  import { createPastedContentMarker, InputBox, shouldCollapsePastedContent, } from "./input-box.js";
@@ -96,6 +97,8 @@ function reconstructDisplayMessages(agentMessages) {
96
97
  args = {};
97
98
  }
98
99
  const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
100
+ if (isHiddenToolMetadata(toolResult ? toolResult.metadata : undefined))
101
+ continue;
99
102
  toolCalls.push({
100
103
  id: tc.id,
101
104
  name: tc.name,