@bubblebrain-ai/bubble 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui/run.js CHANGED
@@ -38,6 +38,11 @@ import { getNextPermissionMode, PERMISSION_MODE_INFO } from "../permission/mode.
38
38
  import { getContextBudget } from "../context/budget.js";
39
39
  import { getLspService } from "../lsp/index.js";
40
40
  import { inferBashPrefix } from "../approval/session-cache.js";
41
+ import { parseGoalCommand } from "../goal/command.js";
42
+ import { continuationPrompt, initialPrompt } from "../goal/prompts.js";
43
+ import { shouldContinueGoal, stopReasonNotice } from "../goal/engine.js";
44
+ import { goalSummaryText, goalIndicatorLine, goalCompleteNotice } from "../goal/format.js";
45
+ import { formatInternalContextBlock } from "../agent/internal-reminder-sanitizer.js";
41
46
  import { collectFeedback } from "../feedback/collect.js";
42
47
  import { submitFeedback, FeedbackSubmitError } from "../feedback/submit.js";
43
48
  import { createFrames } from "./opencode-spinner.js";
@@ -101,6 +106,7 @@ const DEFAULT_THEME = {
101
106
  toolRead: "#9d7cd8",
102
107
  toolWrite: "#f5a742",
103
108
  toolSearch: "#5c9cf5",
109
+ toolMcp: "#d479c9",
104
110
  diffAdded: "#7fd88f",
105
111
  diffRemoved: "#e06c75",
106
112
  diffContext: "#a6acb8",
@@ -145,6 +151,7 @@ const LIGHT_THEME = {
145
151
  toolRead: "#6F55AE",
146
152
  toolWrite: "#8B4A00",
147
153
  toolSearch: "#356FD2",
154
+ toolMcp: "#A03595",
148
155
  diffAdded: "#1E725C",
149
156
  diffRemoved: "#B62633",
150
157
  diffContext: "#6F7377",
@@ -166,6 +173,10 @@ const LOCAL_SLASH_COMMANDS = [
166
173
  name: "toggle-thinking",
167
174
  description: "Toggle thinking block visibility",
168
175
  },
176
+ {
177
+ name: "goal",
178
+ description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
179
+ },
169
180
  {
170
181
  name: "trace",
171
182
  description: "Toggle verbose trace output",
@@ -514,6 +525,28 @@ function OpenTuiApp(props) {
514
525
  const [todos, setTodos] = createSignal(props.agent.getTodos());
515
526
  const [mode, setMode] = createSignal(props.agent.mode);
516
527
  const [notice, setNotice] = createSignal("");
528
+ // Autonomous-goal feature: shared store (also backs the get_goal/update_goal
529
+ // tools), a reactive indicator line, the consecutive auto-continuation
530
+ // counter, and a flag to suppress persistence while loading from the session.
531
+ const goalStore = props.options.goalStore;
532
+ const [goalLine, setGoalLine] = createSignal("");
533
+ let goalPersistSuspended = false;
534
+ if (goalStore) {
535
+ goalStore.onChange((goal) => {
536
+ setGoalLine(goal ? goalIndicatorLine(goal) : "");
537
+ syncSidebarGoal();
538
+ if (!goalPersistSuspended)
539
+ persistGoal(goal);
540
+ });
541
+ const persisted = props.options.sessionManager?.getMetadata().goal;
542
+ if (persisted) {
543
+ goalPersistSuspended = true;
544
+ // Resume-safety: a loaded active goal is parked as paused so it never
545
+ // silently resumes (and spends tokens) on session load. /goal resume runs it.
546
+ goalStore.loadFrom(persisted.status === "active" ? { ...persisted, status: "paused" } : persisted);
547
+ goalPersistSuspended = false;
548
+ }
549
+ }
517
550
  let copyToastClearTimer;
518
551
  let copyToastRoot;
519
552
  let copyToastText;
@@ -564,6 +597,9 @@ function OpenTuiApp(props) {
564
597
  let rootBox;
565
598
  let sidebarShell;
566
599
  let homeSurfaceShell;
600
+ let homeUpdateNotice = props.options.updateNotice;
601
+ let homeUpdateNoticeBox;
602
+ let homeUpdateNoticeText;
567
603
  let transcriptHost;
568
604
  const transcriptState = {
569
605
  entries: [],
@@ -665,6 +701,8 @@ function OpenTuiApp(props) {
665
701
  const sidebarTodoRows = [];
666
702
  const sidebarTodoMarkers = [];
667
703
  const sidebarTodoLabels = [];
704
+ let sidebarGoalSection;
705
+ let sidebarGoalText;
668
706
  const sidebarFileRows = [];
669
707
  const sidebarFileLabels = [];
670
708
  const sidebarFileAdditions = [];
@@ -971,6 +1009,29 @@ function OpenTuiApp(props) {
971
1009
  syncSidebarFiles();
972
1010
  bumpSidebar();
973
1011
  }
1012
+ function syncSidebarGoal() {
1013
+ const line = goalLine();
1014
+ if (sidebarGoalSection)
1015
+ sidebarGoalSection.visible = !!line;
1016
+ if (sidebarGoalText) {
1017
+ sidebarGoalText.content = line || "";
1018
+ sidebarGoalText.requestRender();
1019
+ }
1020
+ sidebarShell?.requestRender();
1021
+ rootBox?.requestRender();
1022
+ }
1023
+ function persistGoal(goal) {
1024
+ const sessionManager = props.options.sessionManager;
1025
+ if (!sessionManager)
1026
+ return;
1027
+ try {
1028
+ const metadata = sessionManager.getMetadata();
1029
+ sessionManager.setMetadata({ ...metadata, goal: goal ?? undefined });
1030
+ }
1031
+ catch {
1032
+ // Persistence is best-effort; never break the run loop over it.
1033
+ }
1034
+ }
974
1035
  function syncSidebarChrome() {
975
1036
  const visible = sidebarVisible();
976
1037
  if (sidebarShell) {
@@ -4912,6 +4973,10 @@ function OpenTuiApp(props) {
4912
4973
  toggleThinkingVisibility();
4913
4974
  return true;
4914
4975
  }
4976
+ if (/^\/goal(?:\s|$)/.test(input.trim())) {
4977
+ await handleGoalCommand(input);
4978
+ return true;
4979
+ }
4915
4980
  if (/^\/(?:trace|verbose|debug)(?:\s|$)/.test(input.trim())) {
4916
4981
  toggleVerboseTrace();
4917
4982
  return true;
@@ -5462,20 +5527,31 @@ function OpenTuiApp(props) {
5462
5527
  addMessage("error", "No model selected. Use /model after /login or provider setup.");
5463
5528
  return;
5464
5529
  }
5465
- rememberPromptHistory(displayInput);
5466
- // History keeps the short marker (it expands again on resend); the
5467
- // transcript shows the full pasted content once the message is sent.
5468
- const displayContent = expandComposerPastedTexts(displayInput);
5469
- const reusedQueuedDisplay = promoteQueuedUserDisplay(options.displayId, displayContent);
5470
- const nextMessages = reusedQueuedDisplay
5471
- ? displayMessages
5472
- : [...displayMessages, { role: "user", content: displayContent }];
5473
- if (!reusedQueuedDisplay)
5474
- displayMessages = nextMessages;
5475
- streamingDisplay = undefined;
5476
- // The user just sent this message — re-engage bottom-follow so the new
5477
- // turn is visible even if they had scrolled up to read earlier history.
5478
- redrawTranscript(undefined, nextMessages, { forceFollow: true });
5530
+ // Goal continuation turns are "hidden": their input is an internal context
5531
+ // block (stripped from the model echo) and must not render a user bubble or
5532
+ // pollute prompt history.
5533
+ const isGoalRun = !!options.goalRun;
5534
+ if (!options.hidden) {
5535
+ rememberPromptHistory(displayInput);
5536
+ // History keeps the short marker (it expands again on resend); the
5537
+ // transcript shows the full pasted content once the message is sent.
5538
+ const displayContent = expandComposerPastedTexts(displayInput);
5539
+ const reusedQueuedDisplay = promoteQueuedUserDisplay(options.displayId, displayContent);
5540
+ const nextMessages = reusedQueuedDisplay
5541
+ ? displayMessages
5542
+ : [...displayMessages, { role: "user", content: displayContent }];
5543
+ if (!reusedQueuedDisplay)
5544
+ displayMessages = nextMessages;
5545
+ streamingDisplay = undefined;
5546
+ // The user just sent this message — re-engage bottom-follow so the new
5547
+ // turn is visible even if they had scrolled up to read earlier history.
5548
+ redrawTranscript(undefined, nextMessages, { forceFollow: true });
5549
+ }
5550
+ else {
5551
+ streamingDisplay = undefined;
5552
+ redrawTranscript(undefined, displayMessages, { forceFollow: true });
5553
+ }
5554
+ let goalRunTokens = 0;
5479
5555
  const taskStartedAt = Date.now();
5480
5556
  const run = beginAgentRun();
5481
5557
  traceEvent("tui_agent_run_begin", {
@@ -5743,6 +5819,8 @@ function OpenTuiApp(props) {
5743
5819
  reasoningTokens: current.reasoningTokens + (event.usage.reasoningTokens ?? 0),
5744
5820
  turns: current.turns + 1,
5745
5821
  }));
5822
+ // Accumulate billed tokens (input + output) toward the goal budget.
5823
+ goalRunTokens += (event.usage.promptTokens || 0) + (event.usage.completionTokens || 0);
5746
5824
  }
5747
5825
  bumpSidebar();
5748
5826
  const currentParts = snapshotDisplayParts(assistantParts);
@@ -5833,6 +5911,143 @@ function OpenTuiApp(props) {
5833
5911
  setTimeout(() => activePrompt()?.focus(), 0);
5834
5912
  if (queuedInputCount() > 0)
5835
5913
  scheduleQueuedInputDrain();
5914
+ maybeContinueGoal({ runCancelled, runErrored: !!runError, isGoalRun, runTokens: goalRunTokens });
5915
+ }
5916
+ }
5917
+ /**
5918
+ * Drives the autonomous goal loop. Called after every agent run finishes:
5919
+ * accounts the goal turn, decides whether to auto-continue, and either fires
5920
+ * the next hidden continuation turn or stops with an explanatory notice.
5921
+ */
5922
+ function maybeContinueGoal(input) {
5923
+ if (!goalStore)
5924
+ return;
5925
+ const current = goalStore.snapshot();
5926
+ if (!current)
5927
+ return;
5928
+ // User interrupt or a provider/run error (out of quota, network down, API
5929
+ // failure) stops the autonomous loop. Pause an active goal so it never
5930
+ // silently retries into a broken provider; the user fixes it and resumes.
5931
+ if (input.runCancelled || input.runErrored) {
5932
+ if (current.status === "active") {
5933
+ goalStore.pause();
5934
+ setNotice(stopReasonNotice(input.runErrored ? "error" : "cancelled"));
5935
+ }
5936
+ return;
5937
+ }
5938
+ // Account the goal turn that just finished (token spend + turn count).
5939
+ if (input.isGoalRun) {
5940
+ if (input.runTokens > 0)
5941
+ goalStore.addTokens(input.runTokens);
5942
+ goalStore.incrementTurn();
5943
+ }
5944
+ const goal = goalStore.snapshot();
5945
+ const decision = shouldContinueGoal({ goal, queuedInputs: queuedInputCount() });
5946
+ if (decision.continue) {
5947
+ const text = formatInternalContextBlock("goal", continuationPrompt(goal));
5948
+ // Start the next turn after this run has fully unwound.
5949
+ queueMicrotask(() => { void runAgentInput(text, "", { hidden: true, goalRun: true }); });
5950
+ return;
5951
+ }
5952
+ if (decision.reason === "budget" && goal.status === "active") {
5953
+ goalStore.markBudgetLimited();
5954
+ }
5955
+ // tokensUsed is now accurate (addTokens ran above), so the completion notice
5956
+ // carries the real final spend — which update_goal could not report mid-run.
5957
+ if (decision.reason === "complete") {
5958
+ setNotice(goalCompleteNotice(goal));
5959
+ return;
5960
+ }
5961
+ const note = stopReasonNotice(decision.reason);
5962
+ if (note)
5963
+ setNotice(note);
5964
+ }
5965
+ // Starts a goal turn unless a run is already in flight (which will continue
5966
+ // the goal when it finishes). When `displayInput` is given (the initial /goal
5967
+ // set), the objective renders as a visible message so the user sees what they
5968
+ // asked for; otherwise the turn is hidden (silent auto-continuation/resume).
5969
+ function kickGoalTurn(prompt, displayInput) {
5970
+ if (isRunning())
5971
+ return;
5972
+ queueMicrotask(() => {
5973
+ void runAgentInput(prompt, displayInput ?? "", { hidden: displayInput === undefined, goalRun: true });
5974
+ });
5975
+ }
5976
+ async function handleGoalCommand(input) {
5977
+ if (!goalStore) {
5978
+ setNotice("Goals are not available in this session");
5979
+ return;
5980
+ }
5981
+ const command = parseGoalCommand(input);
5982
+ if (command.error) {
5983
+ addMessage("error", command.error);
5984
+ return;
5985
+ }
5986
+ const existing = goalStore.snapshot();
5987
+ switch (command.kind) {
5988
+ case "show": {
5989
+ if (!existing) {
5990
+ setNotice("No active goal. Set one with /goal <objective>");
5991
+ }
5992
+ else {
5993
+ setNotice(goalSummaryText(existing));
5994
+ }
5995
+ return;
5996
+ }
5997
+ case "clear": {
5998
+ if (!existing) {
5999
+ setNotice("No active goal to clear");
6000
+ return;
6001
+ }
6002
+ goalStore.clear();
6003
+ setNotice("Goal cleared");
6004
+ return;
6005
+ }
6006
+ case "pause": {
6007
+ if (!existing) {
6008
+ setNotice("No active goal to pause");
6009
+ return;
6010
+ }
6011
+ goalStore.pause();
6012
+ setNotice("Goal paused — /goal resume to continue");
6013
+ return;
6014
+ }
6015
+ case "resume": {
6016
+ if (!existing) {
6017
+ setNotice("No goal to resume. Set one with /goal <objective>");
6018
+ return;
6019
+ }
6020
+ const resumed = goalStore.resume();
6021
+ if (resumed?.status === "active") {
6022
+ setNotice("Goal resumed");
6023
+ kickGoalTurn(formatInternalContextBlock("goal", continuationPrompt(resumed)));
6024
+ }
6025
+ else {
6026
+ setNotice("Goal cannot be resumed (already complete)");
6027
+ }
6028
+ return;
6029
+ }
6030
+ case "edit": {
6031
+ if (!existing) {
6032
+ setNotice("No active goal to edit. Set one with /goal <objective>");
6033
+ return;
6034
+ }
6035
+ goalStore.edit(command.objective);
6036
+ if (command.tokenBudget !== undefined)
6037
+ goalStore.setBudget(command.tokenBudget);
6038
+ setNotice(`Goal updated: ${truncate(goalStore.snapshot().objective, 60)}`);
6039
+ return;
6040
+ }
6041
+ case "set": {
6042
+ const goal = goalStore.set(command.objective, { tokenBudget: command.tokenBudget });
6043
+ const budgetNote = goal.tokenBudget !== undefined ? ` (budget ${goal.tokenBudget} tok)` : "";
6044
+ setNotice(`Goal set${budgetNote} — working autonomously. /goal pause to stop.`);
6045
+ // Echo the full `/goal …` command the user typed as their visible
6046
+ // message (so the transcript and prompt history reflect the invocation);
6047
+ // the model receives the (hidden) initial goal prompt as the turn input.
6048
+ kickGoalTurn(formatInternalContextBlock("goal", initialPrompt(goal)), input.trim());
6049
+ return;
6050
+ }
5836
6051
  }
5837
6052
  }
5838
6053
  function promptUiKeyDown(event) {
@@ -5922,11 +6137,49 @@ function OpenTuiApp(props) {
5922
6137
  }, [
5923
6138
  h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...logoLines.map((line) => renderHomeLogoLine(line))),
5924
6139
  h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center", paddingTop: 1 }, h("text", { fg: theme.textMuted, content: `v${getCurrentVersion()}` })),
5925
- ...(props.options.updateNotice
5926
- ? [h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, h("text", { fg: theme.accent, content: props.options.updateNotice }))]
5927
- : []),
6140
+ // Always mounted so a late registry check can reveal it mid-session.
6141
+ h("box", {
6142
+ ref: (ref) => {
6143
+ homeUpdateNoticeBox = ref;
6144
+ ref.visible = !!homeUpdateNotice;
6145
+ },
6146
+ visible: !!homeUpdateNotice,
6147
+ flexShrink: 0,
6148
+ flexDirection: "column",
6149
+ alignItems: "center",
6150
+ }, h("text", {
6151
+ ref: (ref) => { homeUpdateNoticeText = ref; },
6152
+ fg: theme.accent,
6153
+ content: homeUpdateNotice ?? "",
6154
+ })),
5928
6155
  ]);
5929
6156
  }
6157
+ function watchUpdateNoticeRefresh() {
6158
+ const refresh = props.options.updateNoticeRefresh;
6159
+ if (!refresh)
6160
+ return;
6161
+ refresh.then((notice) => {
6162
+ if (!notice || uiDisposed)
6163
+ return;
6164
+ homeUpdateNotice = notice;
6165
+ if (homeUpdateNoticeText)
6166
+ homeUpdateNoticeText.content = notice;
6167
+ if (homeUpdateNoticeBox)
6168
+ homeUpdateNoticeBox.visible = true;
6169
+ // Already chatting (or resumed straight into a transcript): the home
6170
+ // banner is hidden, so surface the nudge as a transcript line instead.
6171
+ // (Not setNotice: the notice() row in renderSessionView is evaluated
6172
+ // once at initial render and never materializes afterwards.)
6173
+ if (!isHomeSurfaceActive(streamingDisplay))
6174
+ addMessage("assistant", notice);
6175
+ rootBox?.requestRender();
6176
+ }).catch(() => {
6177
+ // The check is best-effort; never disturb the session over it.
6178
+ });
6179
+ }
6180
+ // Component body, not onMount: the onMount callback never fires under the
6181
+ // current @opentui/solid runtime, so anything registered there is dead code.
6182
+ watchUpdateNoticeRefresh();
5930
6183
  function renderQuestionPanelHost() {
5931
6184
  return h("box", {
5932
6185
  ref: (ref) => {
@@ -6684,7 +6937,7 @@ function OpenTuiApp(props) {
6684
6937
  visible: sidebarVisible(),
6685
6938
  flexDirection: "column",
6686
6939
  }, [
6687
- h("scrollbox", { flexGrow: 1, minHeight: 0 }, h("box", { flexDirection: "column", gap: 1, paddingRight: 1 }, renderSidebarTitle(), renderSidebarSection("Context", [
6940
+ h("scrollbox", { flexGrow: 1, minHeight: 0 }, h("box", { flexDirection: "column", gap: 1, paddingRight: 1 }, renderSidebarTitle(), renderSidebarGoal(), renderSidebarSection("Context", [
6688
6941
  h("text", {
6689
6942
  fg: theme.textMuted,
6690
6943
  flexShrink: 0,
@@ -6845,6 +7098,25 @@ function OpenTuiApp(props) {
6845
7098
  }),
6846
7099
  ]);
6847
7100
  }
7101
+ function renderSidebarGoal() {
7102
+ const line = goalLine();
7103
+ return h("box", {
7104
+ flexDirection: "column",
7105
+ flexShrink: 0,
7106
+ visible: !!line,
7107
+ ref: (ref) => {
7108
+ sidebarGoalSection = ref;
7109
+ syncSidebarGoal();
7110
+ },
7111
+ }, h("text", { fg: theme.text }, "Goal"), h("text", {
7112
+ fg: theme.accent,
7113
+ wrapMode: "word",
7114
+ ref: (ref) => {
7115
+ sidebarGoalText = ref;
7116
+ ref.content = goalLine() || "";
7117
+ },
7118
+ }));
7119
+ }
6848
7120
  function renderSidebarTodos(todos) {
6849
7121
  const visible = todos.slice(0, 8);
6850
7122
  return h("box", {
@@ -7864,7 +8136,7 @@ function createTraceGroupRenderable(ctx, group, syntaxStyle, width = 80) {
7864
8136
  }))));
7865
8137
  }
7866
8138
  if (group.omitted > 0) {
7867
- children.push(createText(ctx, ` ... ${group.omitted} more, Ctrl+O to view`, {
8139
+ children.push(createText(ctx, traceGroupOmittedLabel(group), {
7868
8140
  fg: theme.textMuted,
7869
8141
  wrapMode: "word",
7870
8142
  }));
@@ -7882,6 +8154,15 @@ function shouldRenderTraceGroupAsRawTool(tool) {
7882
8154
  function traceGroupDetailLines(group) {
7883
8155
  return group.previewLines.length > 0 ? group.previewLines : group.items;
7884
8156
  }
8157
+ // Overflow hint under a trace group. Line-based details (tool output) read as
8158
+ // "N more lines"; item-based details (file lists) stay as "N more".
8159
+ function traceGroupOmittedLabel(group) {
8160
+ if (group.previewLines.length > 0) {
8161
+ const noun = group.omitted === 1 ? "line" : "lines";
8162
+ return ` ... ${group.omitted} more ${noun}, Ctrl+O to expand`;
8163
+ }
8164
+ return ` ... ${group.omitted} more, Ctrl+O to expand`;
8165
+ }
7885
8166
  const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
7886
8167
  function executeInlineBudget(group, width) {
7887
8168
  return Math.max(14, width - group.title.length - 20);
@@ -7958,9 +8239,14 @@ function traceGroupTitleColor(group) {
7958
8239
  case "edit": return theme.toolWrite;
7959
8240
  case "subagent": return theme.accent;
7960
8241
  case "list": return theme.secondary;
7961
- default: return theme.toolText;
8242
+ default: return isMcpTraceGroup(group) ? theme.toolMcp : theme.toolText;
7962
8243
  }
7963
8244
  }
8245
+ // An "other" group whose single tool is an MCP call (`mcp__<server>__<tool>`).
8246
+ function isMcpTraceGroup(group) {
8247
+ const name = group.raw[0]?.name;
8248
+ return typeof name === "string" && name.startsWith("mcp__");
8249
+ }
7964
8250
  function traceGroupKey(group) {
7965
8251
  return `group:${group.kind}:${group.raw.map((tool) => tool.id).join(":")}`;
7966
8252
  }
@@ -8749,7 +9035,7 @@ function renderTraceGroup(group, syntaxStyle, width = 80) {
8749
9035
  wrapMode: "word",
8750
9036
  }, `${index === 0 ? "↳ " : " "}${truncate(line, detailWidth)}`)))
8751
9037
  : null, group.omitted > 0
8752
- ? h("text", { fg: theme.textMuted, wrapMode: "word" }, ` ... ${group.omitted} more, Ctrl+O to view`)
9038
+ ? h("text", { fg: theme.textMuted, wrapMode: "word" }, traceGroupOmittedLabel(group))
8753
9039
  : null);
8754
9040
  }
8755
9041
  function renderTool(tool, syntaxStyle, width = 80) {
@@ -8864,14 +9150,17 @@ function pickerTitle(kind, providerId) {
8864
9150
  function getModelPickerReasoningLevels(providerId, modelId) {
8865
9151
  // Only expand into one picker row per effort for models that genuinely have a
8866
9152
  // reasoning-effort spectrum: OpenAI's reasoning models (codex gpt-5.x:
8867
- // off/minimal/low/medium/high/xhigh), DeepSeek's v4 models, and StepFun
8868
- // Step Plan models. Other providers
8869
- // (e.g. GLM, Moonshot/Kimi) only have a thinking on/off toggle, not an effort
8870
- // control, so they stay as a single row.
9153
+ // off/minimal/low/medium/high/xhigh), DeepSeek's v4 models, StepFun
9154
+ // Step Plan models, and GLM-5.2 (the only GLM that accepts `reasoning_effort`:
9155
+ // none/minimal/low/medium/high/xhigh/max). Other providers including older
9156
+ // GLM (5.1/4.7/4.6/5-turbo) and Moonshot/Kimi only have a thinking on/off
9157
+ // toggle, not an effort control, so they stay as a single row.
8871
9158
  const isOpenAIReasoning = providerId === "openai" || providerId === "openai-codex";
8872
9159
  const isDeepseekReasoning = providerId === "deepseek" && (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro");
8873
9160
  const isStepFunReasoning = providerId === "stepfun";
8874
- if (!isOpenAIReasoning && !isDeepseekReasoning && !isStepFunReasoning)
9161
+ const isGlm52Reasoning = ["zhipuai", "zhipuai-coding-plan", "zai", "zai-coding-plan"].includes(providerId)
9162
+ && modelId === "glm-5.2";
9163
+ if (!isOpenAIReasoning && !isDeepseekReasoning && !isStepFunReasoning && !isGlm52Reasoning)
8875
9164
  return [];
8876
9165
  const levels = getAvailableThinkingLevels(providerId, modelId);
8877
9166
  // gpt-4o and friends report only ["off"] — keep those as a single row too.
@@ -8884,9 +9173,9 @@ function displayModelWithThinking(model, thinkingLevel) {
8884
9173
  if (!providerId)
8885
9174
  return displayModel(model);
8886
9175
  // Use the same scoping as the picker: only models with a real reasoning-effort
8887
- // spectrum (OpenAI codex gpt-5.x, deepseek v4, StepFun Step Plan) get the
8888
- // "(level)" suffix. The on/off thinking toggle on GLM / Moonshot(Kimi) is
8889
- // not an effort control.
9176
+ // spectrum (OpenAI codex gpt-5.x, deepseek v4, StepFun Step Plan, GLM-5.2) get
9177
+ // the "(level)" suffix. The on/off thinking toggle on older GLM / Moonshot(Kimi)
9178
+ // is not an effort control.
8890
9179
  const levels = getModelPickerReasoningLevels(providerId, modelId);
8891
9180
  if (levels.length > 1 && thinkingLevel !== "off") {
8892
9181
  return `${displayModel(model)} (${thinkingLevel})`;
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import { getEditDiffDetails } from "./edit-diff.js";
3
3
  import { formatSubagentRoute } from "../agent/subagent-route-format.js";
4
+ import { mcpInfoFromString } from "../mcp/name.js";
4
5
  const DEFAULT_MAX_ITEMS = 6;
5
6
  const DEFAULT_MAX_PREVIEW_LINES = 8;
6
7
  export function buildTraceGroups(toolCalls, options = {}) {
@@ -120,13 +121,18 @@ function classifyTool(toolCall) {
120
121
  return { kind: "edit", title: "Edit", bucketKey: `edit:${toolCall.id}`, groupable: false };
121
122
  case "write":
122
123
  return { kind: "write", title: "Write", bucketKey: "write", groupable: true };
123
- default:
124
+ default: {
125
+ const mcp = mcpInfoFromString(toolCall.name);
126
+ const title = mcp
127
+ ? `${mcp.serverName.toUpperCase()}: ${mcp.toolName}`
128
+ : displayToolName(toolCall.name);
124
129
  return {
125
130
  kind: "other",
126
- title: displayToolName(toolCall.name),
131
+ title,
127
132
  bucketKey: `${toolCall.name}:${toolCall.id}`,
128
133
  groupable: false,
129
134
  };
135
+ }
130
136
  }
131
137
  }
132
138
  function buildTraceGroup(classifier, raw, options) {
@@ -345,15 +351,23 @@ function buildSubagentGroup(classifier, tool, options, pending, startedAt) {
345
351
  }
346
352
  function buildOtherGroup(classifier, raw, options, pending, startedAt, hasError, errorCount) {
347
353
  const tool = raw[0];
348
- const header = toolHeader(tool, options.homeDir);
354
+ const mcp = mcpInfoFromString(tool.name);
355
+ // MCP tools carry arbitrary args, so render them as `key: value` pairs inline
356
+ // (via the `command` slot) instead of the path-based header used for builtins.
357
+ const header = mcp ? undefined : toolHeader(tool, options.homeDir);
358
+ const argsLabel = mcp ? mcpArgsLabel(tool.args) : "";
359
+ // Suppress the "N calls" fallback for MCP tools — the title already names the
360
+ // tool, and args (when present) ride alongside it.
361
+ const hasInline = mcp || !!header;
349
362
  const preview = resultLines(tool.result).map((line) => formatTracePath(line, options.homeDir));
350
363
  const { shown, omitted } = take(preview, options.maxPreviewLines);
351
364
  return {
352
365
  kind: "other",
353
366
  title: classifier.title,
354
367
  raw,
355
- count: header ? undefined : raw.length,
356
- noun: header ? undefined : plural(raw.length, "call", "calls"),
368
+ command: argsLabel || undefined,
369
+ count: hasInline ? undefined : raw.length,
370
+ noun: hasInline ? undefined : plural(raw.length, "call", "calls"),
357
371
  items: header ? [header] : [],
358
372
  previewLines: shown,
359
373
  errorLines: [],
@@ -469,6 +483,28 @@ function displayToolName(name) {
469
483
  return "Tool";
470
484
  return name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " ");
471
485
  }
486
+ /** Compact `key: value, key: value` rendering of an MCP tool's arguments. */
487
+ function mcpArgsLabel(args) {
488
+ if (!args || typeof args !== "object")
489
+ return "";
490
+ return Object.entries(args)
491
+ .filter(([, value]) => value !== undefined)
492
+ .map(([key, value]) => `${key}: ${formatMcpArgValue(value)}`)
493
+ .join(", ");
494
+ }
495
+ function formatMcpArgValue(value) {
496
+ if (typeof value === "string")
497
+ return JSON.stringify(value);
498
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
499
+ return String(value);
500
+ }
501
+ try {
502
+ return JSON.stringify(value);
503
+ }
504
+ catch {
505
+ return String(value);
506
+ }
507
+ }
472
508
  function toolHeader(tool, homeDir) {
473
509
  const args = tool.args || {};
474
510
  for (const key of ["path", "command", "pattern", "query", "url"]) {
@@ -25,6 +25,8 @@ export interface RunTuiOptions {
25
25
  settingsManager?: SettingsManager;
26
26
  lspService?: LspService;
27
27
  mcpManager?: McpManager;
28
+ /** Accepted for compatibility with the shared options bag; the goal feature is OpenTUI-only. */
29
+ goalStore?: import("../goal/store.js").GoalStore;
28
30
  themeMode?: ThemeMode;
29
31
  themeOverrides?: Record<string, string>;
30
32
  detectedTheme?: ResolvedTheme;
@@ -37,10 +37,24 @@ export declare function upgradeCommandFor(manager: PackageManager): {
37
37
  export declare function runUpdateCommand(opts?: {
38
38
  checkOnly?: boolean;
39
39
  }): Promise<number>;
40
+ export interface StartupUpdateCheck {
41
+ /** Notice derived from the local cache — available immediately, no network. */
42
+ notice: string | null;
43
+ /**
44
+ * Resolves once the background registry check completes: a notice string
45
+ * when it finds a version newer than both the running one and the cached
46
+ * `notice`, otherwise null. Never rejects.
47
+ */
48
+ refreshed: Promise<string | null>;
49
+ }
40
50
  /**
41
- * Returns a one-line "update available" notice if the cached latest version is
42
- * newer than the running one. Reads only a local cache file (fast, no network
43
- * on the hot path); a stale cache triggers a fire-and-forget refresh so the
44
- * next launch is accurate. Never throws.
51
+ * Startup "update available" check. The immediate `notice` comes from the
52
+ * local cache file (fast, no network on the hot path). A registry refresh
53
+ * always runs in the background (throttled to once per 30 minutes) so a
54
+ * release published since the last launch surfaces in the *current* session
55
+ * via `refreshed`, instead of only after the cache TTL plus another restart.
56
+ * Never throws.
45
57
  */
58
+ export declare function startStartupUpdateCheck(): Promise<StartupUpdateCheck>;
59
+ /** Cache-only variant of {@link startStartupUpdateCheck} (still refreshes in the background). */
46
60
  export declare function getStartupUpdateNotice(): Promise<string | null>;