@bubblebrain-ai/bubble 0.0.49 → 0.0.51

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 (64) hide show
  1. package/dist/agent/abort-errors.d.ts +4 -0
  2. package/dist/agent/abort-errors.js +30 -0
  3. package/dist/agent/profiles.js +3 -4
  4. package/dist/agent/provider-fingerprint.d.ts +6 -0
  5. package/dist/agent/provider-fingerprint.js +89 -0
  6. package/dist/agent/resident-history.d.ts +7 -0
  7. package/dist/agent/resident-history.js +61 -0
  8. package/dist/agent/subtask-policy.js +4 -4
  9. package/dist/agent/transcript-helpers.d.ts +20 -0
  10. package/dist/agent/transcript-helpers.js +62 -0
  11. package/dist/agent/update-queue.d.ts +13 -0
  12. package/dist/agent/update-queue.js +69 -0
  13. package/dist/agent.d.ts +33 -26
  14. package/dist/agent.js +80 -334
  15. package/dist/debug-trace.js +0 -2
  16. package/dist/external-runtime/grok-runtime.js +1 -1
  17. package/dist/feishu/agent-host/run-driver.js +1 -8
  18. package/dist/feishu/card/run-state.js +0 -3
  19. package/dist/lsp/service.js +2 -2
  20. package/dist/main.js +0 -15
  21. package/dist/memory/phase1.js +0 -2
  22. package/dist/model-catalog.js +8 -6
  23. package/dist/permissions/settings.d.ts +0 -1
  24. package/dist/permissions/settings.js +0 -2
  25. package/dist/prompt/compose.js +0 -3
  26. package/dist/prompt/environment.js +0 -2
  27. package/dist/prompt/reminders.js +1 -1
  28. package/dist/provider-registry.js +13 -1
  29. package/dist/provider-transform.js +7 -8
  30. package/dist/sdk/index.d.ts +17 -2
  31. package/dist/sdk/index.js +37 -6
  32. package/dist/session-log.d.ts +2 -4
  33. package/dist/session-log.js +0 -23
  34. package/dist/session-types.d.ts +2 -6
  35. package/dist/session.d.ts +1 -3
  36. package/dist/session.js +0 -8
  37. package/dist/slash-commands/commands.js +13 -13
  38. package/dist/tools/child-tools.d.ts +1 -1
  39. package/dist/tools/child-tools.js +1 -1
  40. package/dist/tools/grep.js +1 -1
  41. package/dist/tools/index.d.ts +0 -3
  42. package/dist/tools/index.js +0 -3
  43. package/dist/tui-ink/app-helpers.d.ts +43 -0
  44. package/dist/tui-ink/app-helpers.js +150 -0
  45. package/dist/tui-ink/app.d.ts +4 -11
  46. package/dist/tui-ink/app.js +57 -657
  47. package/dist/tui-ink/command-palette.d.ts +32 -0
  48. package/dist/tui-ink/command-palette.js +158 -0
  49. package/dist/tui-ink/display-reconstruct.d.ts +3 -0
  50. package/dist/tui-ink/display-reconstruct.js +82 -0
  51. package/dist/tui-ink/message-list.js +0 -1
  52. package/dist/tui-ink/rewind-picker.d.ts +8 -0
  53. package/dist/tui-ink/rewind-picker.js +102 -0
  54. package/dist/tui-ink/session-picker.js +1 -1
  55. package/dist/tui-ink/stats-panel.d.ts +11 -0
  56. package/dist/tui-ink/stats-panel.js +65 -0
  57. package/dist/tui-ink/waiting-indicator.d.ts +12 -0
  58. package/dist/tui-ink/waiting-indicator.js +129 -0
  59. package/dist/types.d.ts +0 -9
  60. package/package.json +1 -1
  61. package/dist/tools/todo.d.ts +0 -12
  62. package/dist/tools/todo.js +0 -136
  63. package/dist/tui-ink/todos.d.ts +0 -7
  64. package/dist/tui-ink/todos.js +0 -46
@@ -1,18 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Box, Text, useApp, useInput } from "ink";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
4
  import { AgentAbortError, INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
5
- import { isHiddenToolMetadata } from "../agent/tool-visibility.js";
6
5
  import { SessionManager } from "../session.js";
7
6
  import { registry as slashRegistry } from "../slash-commands/index.js";
8
7
  import { UserConfig, maskKey } from "../config.js";
9
8
  import { InputBox, isCtrlCInput, isCtrlLetterInput, } from "./input-box.js";
10
9
  import { MessageList } from "./message-list.js";
11
10
  import { isMultiplexedTerminal } from "./terminal-env.js";
12
- import { appendTextPart, appendToolPart, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
11
+ import { appendTextPart, appendToolPart, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
13
12
  import { AgentRunInputQueue } from "../agent/input-controller.js";
14
- import { canvasBackgroundFor, paletteFor, ThemeProvider, useTheme } from "./theme.js";
15
- import { isPrintablePickerInput, ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
13
+ import { canvasBackgroundFor, paletteFor, ThemeProvider } from "./theme.js";
14
+ import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
16
15
  import { FeishuSetupPicker } from "./feishu-setup-picker.js";
17
16
  import { BUILTIN_PROVIDERS, ProviderRegistry, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
18
17
  import { buildSystemPrompt } from "../system-prompt.js";
@@ -24,7 +23,6 @@ import { useTerminalSize } from "./use-terminal-size.js";
24
23
  import { WelcomeBanner, shouldShowWelcomeBanner } from "./welcome.js";
25
24
  import { BubbleCodeWordmark } from "./wordmark.js";
26
25
  import { expandAtMentions } from "./file-mentions.js";
27
- import { TodosPanel } from "./todos.js";
28
26
  import { CompactionProgressCard } from "./compaction-progress.js";
29
27
  import { PlanConfirm } from "./plan-confirm.js";
30
28
  import { ApprovalDialog } from "./approval/approval-dialog.js";
@@ -49,234 +47,25 @@ import { tokenUsageTotal } from "../goal/usage.js";
49
47
  import { formatInternalContextBlock, isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
50
48
  import { MAX_ACTIVE_LOOPS, decideLoopFiring, formatInterval, formatLoopList, parseLoopCommand, } from "../loop/engine.js";
51
49
  import { TaskWakeCoalescer, findDanglingTaskStarts, formatTaskWakeSummary, isPidAlive, shouldFireTaskWake, taskEligibleForWake, } from "../tasks/wake.js";
52
- const TASK_WAKE_DEBOUNCE_MS = 2000;
53
- import { collectUsageStatsBundle, formatStatsPanelBody, rangeLabel } from "../stats/usage.js";
50
+ import { STREAMING_FLUSH_INTERVAL_MS, TASK_WAKE_DEBOUNCE_MS, buildTips, formatContextUsageLabel, friendlyCwd, parsePartialArgs, sessionBasename, slashResultNoticeKind, taskRowSummary, truncate, withMessageKey, } from "./app-helpers.js";
51
+ import { reconstructDisplayMessages } from "./display-reconstruct.js";
52
+ export { reconstructDisplayMessages } from "./display-reconstruct.js";
53
+ import { INK_LOCAL_SLASH_COMMANDS, CommandPalette, McpReconnectPicker, buildCommandPaletteItems, buildMcpReconnectItems, } from "./command-palette.js";
54
+ export { INK_LOCAL_SLASH_COMMANDS } from "./command-palette.js";
55
+ import { RewindPicker } from "./rewind-picker.js";
56
+ import { StatsPanel } from "./stats-panel.js";
57
+ import { WaitingIndicator } from "./waiting-indicator.js";
58
+ import { collectUsageStatsBundle } from "../stats/usage.js";
54
59
  import { GrokRuntimeError } from "../external-runtime/grok-errors.js";
55
- import { GROK_LOCAL_SLASH_COMMANDS, classifyGrokInput, isGrokLocalSlashCommand, } from "../external-runtime/grok-input-policy.js";
60
+ import { GROK_LOCAL_SLASH_COMMANDS, classifyGrokInput, } from "../external-runtime/grok-input-policy.js";
56
61
  import { GROK_SUBSCRIPTION_PROVIDER_ID, isGrokSubscriptionProviderId, withGrokSubscriptionProvider, } from "../external-runtime/grok-provider.js";
57
62
  import { classifyExternalRuntimeBinding, stopExternalRuntimeForSessionSwitch, } from "../external-runtime/session-policy.js";
58
- import os from "node:os";
59
63
  import { rmSync } from "node:fs";
60
64
  import { execFile } from "node:child_process";
61
- function buildTips(agent, registry, grokActive = false) {
62
- const tips = [];
63
- if (grokActive) {
64
- return [
65
- "Grok Subscription is active with workspace tools and Bubble approvals",
66
- "Use /model to choose the Grok model and reasoning effort",
67
- "Use /logout grok to return to a fresh native session",
68
- ];
69
- }
70
- const hasProvider = registry.getEnabled().length > 0;
71
- if (!hasProvider) {
72
- tips.push("Run /login or /provider --add to configure a model");
73
- }
74
- else if (agent.model) {
75
- tips.push(`Ready with ${displayModel(agent.model)}`);
76
- }
77
- else {
78
- tips.push("Run /model to pick a model");
79
- }
80
- tips.push("Type @ to reference a file");
81
- tips.push("Type / for commands and skills");
82
- return tips;
83
- }
84
- /**
85
- * Slash-command results arrive as plain strings; recognize the /model
86
- * confirmations by shape so they render as accent-colored UI notices — the
87
- * same treatment the picker path applies — instead of assistant prose.
88
- */
89
- function slashResultNoticeKind(result) {
90
- return /^(Grok m|M)odel switched to /.test(result) ? "ui_notice" : undefined;
91
- }
92
65
  // Canonical implementation lives in the sanitizer module so non-TUI surfaces
93
66
  // (session pickers, desktop host) share it; re-exported here for existing users.
94
67
  export { isInternalBlockOnlyContent };
95
- /** Status-row summary for running background tasks (design §2.5). */
96
- function taskRowSummary(tasks, nowTick) {
97
- const first = tasks[0];
98
- const label = first.description?.trim() || first.command.replace(/\s+/g, " ").slice(0, 32);
99
- const elapsed = Math.max(0, Math.round((nowTick - first.startedAt) / 1000));
100
- return `${label} ${elapsed}s`;
101
- }
102
- function friendlyCwd(cwd) {
103
- const home = os.homedir();
104
- if (cwd === home)
105
- return "~";
106
- if (cwd.startsWith(home + "/"))
107
- return "~" + cwd.slice(home.length);
108
- return cwd;
109
- }
110
- function sessionBasename(sessionFile) {
111
- if (!sessionFile)
112
- return undefined;
113
- const base = sessionFile.split("/").pop() ?? sessionFile;
114
- return base.replace(/\.jsonl$/, "");
115
- }
116
- function truncate(value, max) {
117
- if (value.length <= max)
118
- return value;
119
- return `${value.slice(0, Math.max(0, max - 1))}…`;
120
- }
121
- function formatCompactTokens(n) {
122
- if (n < 1000)
123
- return `${Math.round(n)}`;
124
- if (n < 1_000_000)
125
- return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}K`;
126
- return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
127
- }
128
- function formatContextUsageLabel(snapshot) {
129
- if (!snapshot.contextWindow || snapshot.contextWindow <= 0) {
130
- return `${formatCompactTokens(snapshot.usedTokens)} context`;
131
- }
132
- const percent = (snapshot.usedTokens / snapshot.contextWindow) * 100;
133
- const label = percent >= 10
134
- ? `${Math.round(percent)}%`
135
- : percent >= 0.05
136
- ? `${percent.toFixed(1)}%`
137
- : "<0.1%";
138
- return `${label} context`;
139
- }
140
- export function reconstructDisplayMessages(agentMessages) {
141
- const result = [];
142
- for (const m of agentMessages) {
143
- if (m.role === "system" || m.role === "tool")
144
- continue;
145
- if (m.role === "user") {
146
- if (m.isMeta)
147
- continue; // <system-reminder> injections are not user-visible
148
- // Harness-initiated kicks (goal continuations, task wakes) persist as
149
- // user-role messages wrapped in internal blocks so the model keeps
150
- // them across resume — but they were hidden live and must stay hidden
151
- // when the transcript is rebuilt.
152
- if (isInternalBlockOnlyContent(m.content))
153
- continue;
154
- result.push({
155
- key: nextDisplayMessageKey("user"),
156
- role: "user",
157
- content: typeof m.content === "string" ? m.content : "(multimedia)",
158
- });
159
- }
160
- else if (m.role === "assistant") {
161
- const toolCalls = [];
162
- if (m.toolCalls) {
163
- for (const tc of m.toolCalls) {
164
- let args = {};
165
- try {
166
- args = JSON.parse(tc.arguments || "{}");
167
- }
168
- catch {
169
- args = {};
170
- }
171
- const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
172
- if (isHiddenToolMetadata(toolResult ? toolResult.metadata : undefined))
173
- continue;
174
- toolCalls.push({
175
- id: tc.id,
176
- name: tc.name,
177
- args,
178
- result: toolResult ? toolResult.content : undefined,
179
- isError: toolResult ? toolResult.content?.startsWith?.("Error:") : false,
180
- metadata: toolResult ? toolResult.metadata : undefined,
181
- });
182
- }
183
- }
184
- // An aborted assistant message carries the model-facing interruption
185
- // note in its content. Render only what the assistant actually said
186
- // (partial streamed text, if any) plus a dedicated interrupt row —
187
- // never the note itself, which reads like a leaked system prompt.
188
- const interrupted = m.error?.aborted === true;
189
- const content = interrupted
190
- ? stripInterruptedAssistantMarker(m.content, INTERRUPTED_ASSISTANT_CONTENT)
191
- : m.content;
192
- if (content || m.reasoning || toolCalls.length > 0 || m.systemFingerprint) {
193
- result.push({
194
- key: nextDisplayMessageKey("asst"),
195
- role: "assistant",
196
- content,
197
- reasoning: m.reasoning || undefined,
198
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
199
- systemFingerprint: m.systemFingerprint,
200
- });
201
- }
202
- if (interrupted) {
203
- result.push({
204
- key: nextDisplayMessageKey("asst"),
205
- role: "assistant",
206
- content: "Interrupted by user",
207
- syntheticKind: "ui_interrupt",
208
- });
209
- }
210
- }
211
- }
212
- return result;
213
- }
214
- /**
215
- * Streaming tool arguments arrive as an incomplete JSON buffer. We can't
216
- * JSON.parse() until the closing brace lands, but the user wants to see the
217
- * short identifying fields (path, command, …) as soon as the model emits
218
- * them so the tool row header reflects what's happening.
219
- *
220
- * Intentionally limited to short, single-line fields. Long fields like
221
- * `content` are *not* surfaced live: rendering thousands of partial lines
222
- * per delta floods the terminal and the partial value can break around
223
- * unescaped sequences. The final value lands when the tool actually
224
- * executes and tool_start delivers canonical args.
225
- */
226
- function parsePartialArgs(buffer, previous) {
227
- // If the buffer is now valid JSON, prefer the real parse.
228
- try {
229
- const parsed = JSON.parse(buffer);
230
- if (parsed && typeof parsed === "object")
231
- return parsed;
232
- }
233
- catch {
234
- // fall through to partial extraction below
235
- }
236
- const result = { ...previous };
237
- const FIELDS = ["path", "command", "pattern", "url", "query"];
238
- for (const field of FIELDS) {
239
- // Match a complete-looking quoted string. Requires a closing quote so we
240
- // don't surface half-typed paths that may still change as bytes arrive.
241
- const match = buffer.match(new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`));
242
- if (match) {
243
- const raw = match[1] ?? "";
244
- result[field] = raw
245
- .replace(/\\n/g, "\n")
246
- .replace(/\\t/g, "\t")
247
- .replace(/\\"/g, '"')
248
- .replace(/\\\\/g, "\\");
249
- }
250
- }
251
- return result;
252
- }
253
- /**
254
- * Coerce a freshly-constructed DisplayMessage into one that carries a stable
255
- * `key`. Centralizes the safety net so callers don't have to remember to call
256
- * nextDisplayMessageKey on every push.
257
- */
258
- function withMessageKey(message) {
259
- if (message.key)
260
- return message;
261
- const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
262
- return { ...message, key: nextDisplayMessageKey(prefix) };
263
- }
264
- // Batch streaming text deltas before committing them to React state. Without
265
- // <Static>, every commit re-renders the full-screen frame; per-token commits
266
- // would make Yoga re-lay-out the transcript for every few bytes of output.
267
- // 40ms keeps perceived latency invisible while capping layout work at 25fps.
268
- const STREAMING_FLUSH_INTERVAL_MS = 40;
269
- export const INK_LOCAL_SLASH_COMMANDS = [
270
- {
271
- name: "goal",
272
- description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
273
- },
274
- {
275
- name: "loop",
276
- description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])",
277
- },
278
- ];
279
- export function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, processManager, tasksAutoResume, promotionChannel, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, externalRuntime, onExit }) {
68
+ export function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, processManager, tasksAutoResume, promotionChannel, updateNotice, updateNoticeRefresh, hookController, externalRuntime, onExit }) {
280
69
  const [sessionManager, setSessionManager] = useState(initialSessionManager);
281
70
  const [externalRuntimeBinding, setExternalRuntimeBinding] = useState(() => initialSessionManager?.getMetadata().externalRuntime);
282
71
  const sessionManagerRef = useRef(sessionManager);
@@ -356,7 +145,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
356
145
  // model ladder.
357
146
  const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
358
147
  const [permissionMode, setPermissionMode] = useState(agent.mode);
359
- const [todos, setTodos] = useState(() => agent.getTodos());
360
148
  const [goalLine, setGoalLine] = useState("");
361
149
  const [branch, setBranch] = useState(undefined);
362
150
  const [contextUsage, setContextUsage] = useState("");
@@ -372,8 +160,30 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
372
160
  });
373
161
  return () => { cancelled = true; };
374
162
  }, [args.cwd]);
163
+ // Push-based sync: the agent notifies on every resident-context mutation
164
+ // (message rewrites from /clear /compact /rewind and session switches,
165
+ // per-message appends during runs, model/provider switches), so this single
166
+ // subscription replaces the per-path manual refreshes that were easy to
167
+ // forget (the /clear footer bug). Initial read covers agents mounted with
168
+ // pre-existing history.
169
+ //
170
+ // Notifications are trailing-edge debounced: a snapshot run is O(all
171
+ // resident messages) (tiktoken providers measured ~225ms at 400 messages),
172
+ // and a tool-dense turn appends 5-15 messages synchronously — recomputing
173
+ // per append would block the run loop 1-3s. The footer is a human-facing
174
+ // readout; one recomputation 150ms after the last append is indistinguishable.
375
175
  useEffect(() => {
376
- setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
176
+ let timer;
177
+ const update = () => setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
178
+ update();
179
+ const off = agent.onContextChanged(() => {
180
+ clearTimeout(timer);
181
+ timer = setTimeout(update, 150);
182
+ });
183
+ return () => {
184
+ clearTimeout(timer);
185
+ off();
186
+ };
377
187
  }, [agent]);
378
188
  const [currentUpdateNotice, setCurrentUpdateNotice] = useState(updateNotice);
379
189
  const [pendingPlan, setPendingPlan] = useState(null);
@@ -403,12 +213,19 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
403
213
  // MessageList so Ink discards its already-printed rows and re-prints the
404
214
  // rebuilt list onto a freshly-cleared screen instead of appending duplicates.
405
215
  const [staticGeneration, setStaticGeneration] = useState(0);
216
+ // The clear must go through Ink's stdout hook, not a raw process.stdout
217
+ // write: Ink replays its last dynamic frame after any external write
218
+ // (restoreLastOutput), which is the only repaint guarantee when the
219
+ // generation bump produces no new output — with zero settled rows (fresh
220
+ // welcome screen) <Static> emits nothing and the dynamic frame diffs equal,
221
+ // so Ink skips the write and a raw clear would leave the screen blank.
222
+ const { write: writeThroughInk } = useStdout();
406
223
  const reprintTranscript = useCallback(() => {
407
224
  if (process.stdout.isTTY) {
408
- process.stdout.write("\x1b[0m\x1b[2J\x1b[3J\x1b[H");
225
+ writeThroughInk("\x1b[0m\x1b[2J\x1b[3J\x1b[H");
409
226
  }
410
227
  setStaticGeneration((generation) => generation + 1);
411
- }, []);
228
+ }, [writeThroughInk]);
412
229
  // Steer/queue while the agent runs:
413
230
  // Enter steers the current run via the agent's input controller; Tab (or an
414
231
  // ineligible input) queues for the next turn. Both render placeholder user
@@ -825,10 +642,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
825
642
  // <Static>), so clearing React state is not enough — resetTranscript wipes
826
643
  // the screen + scrollback and re-prints the (now empty) transcript.
827
644
  resetTranscript(() => []);
828
- // The todos panel renders off React state, not the transcript, so wiping
829
- // messages alone leaves a stale To-Do list on screen. /clear already reset
830
- // the agent's todos; mirror that into the UI (same as session switch).
831
- setTodos(agent.getTodos());
832
645
  clearLiveSubagentTools();
833
646
  }, [resetTranscript, agent, clearLiveSubagentTools]);
834
647
  // Render a placeholder user row for input waiting to enter the run.
@@ -986,7 +799,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
986
799
  const nextExternalBinding = result.manager.getMetadata().externalRuntime;
987
800
  externalRuntimeBindingRef.current = nextExternalBinding;
988
801
  setExternalRuntimeBinding(nextExternalBinding);
989
- setTodos(agent.getTodos());
990
802
  clearLiveSubagentTools();
991
803
  resetTranscript(() => [
992
804
  ...reconstructDisplayMessages(agent.messages).filter((message) => !queuedDisplayKeys.has(message.key ?? "")),
@@ -1098,6 +910,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1098
910
  setThinkingLevel,
1099
911
  sessionManager: targetSessionManager,
1100
912
  });
913
+ // Persist the resolved effort alongside the model so a fresh session
914
+ // restores both. Without this, the picker path only remembered the model
915
+ // and new sessions fell back to a stale config.json defaultThinkingLevel
916
+ // (e.g. picking glm-5.3 + max still restored "high" on next launch).
917
+ userConfig.setDefaultThinkingLevel(nextThinkingLevel);
1101
918
  // Binary thinking toggles and thinking-only models use internal level
1102
919
  // placeholders; do not present those placeholders as real effort grades.
1103
920
  const decodedModel = decodeModel(model);
@@ -1107,7 +924,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1107
924
  const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
1108
925
  ? (isThinkingToggle || isThinkingOnly ? " in thinking mode" : ` with ${nextThinkingLevel} effort`)
1109
926
  : "";
1110
- setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
1111
927
  addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`, "ui_notice");
1112
928
  closePicker();
1113
929
  return nextThinkingLevel;
@@ -1167,8 +983,10 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1167
983
  transitionToNative,
1168
984
  onExternalRuntimeChange: refreshExternalRuntimeBinding,
1169
985
  });
1170
- if (handled && result)
1171
- addMessage("assistant", result, slashResultNoticeKind(result));
986
+ if (handled) {
987
+ if (result)
988
+ addMessage("assistant", result, slashResultNoticeKind(result));
989
+ }
1172
990
  }, [
1173
991
  addMessage,
1174
992
  agent,
@@ -1688,10 +1506,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1688
1506
  }
1689
1507
  break;
1690
1508
  }
1691
- case "todos_updated": {
1692
- setTodos(event.todos);
1693
- break;
1694
- }
1695
1509
  case "mode_changed": {
1696
1510
  // Marker already persisted by onModeUpdate at setMode time.
1697
1511
  setPermissionMode(event.mode);
@@ -1758,7 +1572,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1758
1572
  goalRunTokens += tokenUsageTotal(event.usage);
1759
1573
  }
1760
1574
  assistantSystemFingerprint = event.systemFingerprint;
1761
- setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
1762
1575
  if (event.willContinue) {
1763
1576
  commitAssistantMessage();
1764
1577
  clearAssistantStream();
@@ -2494,7 +2307,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2494
2307
  }, onCancel: () => {
2495
2308
  closePicker();
2496
2309
  addMessage("assistant", "已取消 Feishu setup。");
2497
- } }) })), statsPanel && !pickerMode && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(StatsPanel, { panel: statsPanel, terminalColumns: mainWidth, terminalRows: terminalRows, onRangeChange: (range) => setStatsPanel((current) => current ? { ...current, range } : current), onCancel: closeStatsPanel }) })), todos.length > 0 && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !statsPanel && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
2310
+ } }) })), statsPanel && !pickerMode && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(StatsPanel, { panel: statsPanel, terminalColumns: mainWidth, terminalRows: terminalRows, onRangeChange: (range) => setStatsPanel((current) => current ? { ...current, range } : current), onCancel: closeStatsPanel }) })), pendingPlan && !pickerMode && !statsPanel && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
2498
2311
  const resolve = pendingPlan.resolve;
2499
2312
  setPendingPlan(null);
2500
2313
  resolve({ action: "approve", plan: finalPlan });
@@ -2562,416 +2375,3 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2562
2375
  contextUsage: externalSessionBound ? undefined : (contextUsage || undefined),
2563
2376
  }) }) }))] }) }));
2564
2377
  }
2565
- function buildCommandPaletteItems(skillRegistry, grokSessionBound = false) {
2566
- const items = new Map();
2567
- const add = (item) => {
2568
- const key = `${item.action ?? "command"}:${item.value}`;
2569
- if (!items.has(key))
2570
- items.set(key, item);
2571
- };
2572
- if (!grokSessionBound) {
2573
- for (const command of INK_LOCAL_SLASH_COMMANDS) {
2574
- add({
2575
- label: `/${command.name}`,
2576
- detail: command.description,
2577
- value: command.name,
2578
- command: `/${command.name}`,
2579
- });
2580
- }
2581
- }
2582
- for (const command of slashRegistry.list()) {
2583
- if (grokSessionBound && !isGrokLocalSlashCommand(command.name))
2584
- continue;
2585
- const source = command.source === "mcp" ? " :mcp" : "";
2586
- const sourceLabel = command.sourceLabel ? `[${command.sourceLabel}] ` : "";
2587
- add({
2588
- label: `/${command.name}${source}`,
2589
- detail: `${sourceLabel}${command.description}`,
2590
- value: command.name,
2591
- command: `/${command.name}`,
2592
- });
2593
- }
2594
- if (!grokSessionBound) {
2595
- for (const skill of skillRegistry.summaries()) {
2596
- add({
2597
- label: `/${skill.name} :skill`,
2598
- detail: `[${skill.source}] ${skill.description}`,
2599
- value: skill.name,
2600
- command: `/${skill.name}`,
2601
- action: "insert-skill",
2602
- });
2603
- }
2604
- }
2605
- return [...items.values()];
2606
- }
2607
- function buildMcpReconnectItems(mcpManager) {
2608
- return (mcpManager?.getStates() ?? []).map((state) => {
2609
- let detail;
2610
- if (state.status.kind === "connected") {
2611
- const tools = state.status.tools.length;
2612
- const prompts = state.status.prompts.length;
2613
- detail = `connected · ${tools} tool${tools === 1 ? "" : "s"} · ${prompts} prompt${prompts === 1 ? "" : "s"}`;
2614
- }
2615
- else if (state.status.kind === "failed") {
2616
- detail = `failed · ${state.status.error}`;
2617
- }
2618
- else {
2619
- detail = "disabled";
2620
- }
2621
- return {
2622
- label: state.name,
2623
- detail,
2624
- value: state.name,
2625
- command: `/mcp reconnect ${state.name}`,
2626
- };
2627
- });
2628
- }
2629
- function CommandPalette({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
2630
- return (_jsx(PalettePicker, { title: "Commands", hint: "Type to filter \u00B7 Up/Down choose \u00B7 Enter run \u00B7 Esc cancel", emptyText: "No commands found.", items: items, terminalColumns: terminalColumns, terminalRows: terminalRows, searchable: true, onSelect: onSelect, onCancel: onCancel }));
2631
- }
2632
- function McpReconnectPicker({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
2633
- return (_jsx(PalettePicker, { title: "MCP servers", hint: "Up/Down choose \u00B7 Enter or r reconnect \u00B7 Esc cancel", emptyText: "No MCP servers configured.", items: items, terminalColumns: terminalColumns, terminalRows: terminalRows, reconnectAlias: true, onSelect: onSelect, onCancel: onCancel }));
2634
- }
2635
- function PalettePicker({ title, hint, emptyText, items, terminalColumns, terminalRows, searchable = false, reconnectAlias = false, onSelect, onCancel, }) {
2636
- const theme = useTheme();
2637
- const [query, setQuery] = useState("");
2638
- const [selectedIndex, setSelectedIndex] = useState(0);
2639
- const maxVisible = Math.max(5, Math.min(12, terminalRows - 10));
2640
- const filtered = useMemo(() => {
2641
- const needle = query.trim().toLowerCase();
2642
- if (!needle)
2643
- return items;
2644
- return items.filter((item) => item.label.toLowerCase().includes(needle) ||
2645
- item.detail.toLowerCase().includes(needle) ||
2646
- item.value.toLowerCase().includes(needle));
2647
- }, [items, query]);
2648
- useEffect(() => {
2649
- setSelectedIndex((current) => Math.min(Math.max(0, filtered.length - 1), current));
2650
- }, [filtered.length]);
2651
- useInput((input, key) => {
2652
- if (isKeyReleaseEvent(key))
2653
- return;
2654
- if (key.escape) {
2655
- onCancel();
2656
- return;
2657
- }
2658
- if (key.return || (reconnectAlias && input.toLowerCase() === "r")) {
2659
- const item = filtered[selectedIndex];
2660
- if (item)
2661
- onSelect(item);
2662
- return;
2663
- }
2664
- if (key.upArrow) {
2665
- setSelectedIndex((index) => Math.max(0, index - 1));
2666
- return;
2667
- }
2668
- if (key.downArrow) {
2669
- setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + 1));
2670
- return;
2671
- }
2672
- if (key.pageUp) {
2673
- setSelectedIndex((index) => Math.max(0, index - maxVisible));
2674
- return;
2675
- }
2676
- if (key.pageDown) {
2677
- setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + maxVisible));
2678
- return;
2679
- }
2680
- if (!searchable)
2681
- return;
2682
- if (key.backspace || key.delete) {
2683
- setQuery((current) => current.slice(0, -1));
2684
- return;
2685
- }
2686
- if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
2687
- setQuery((current) => current + input);
2688
- }
2689
- });
2690
- const start = clampWindowStartForIndex(filtered.length, selectedIndex, maxVisible);
2691
- const visible = filtered.slice(start, start + maxVisible);
2692
- const labelWidth = Math.max(18, Math.min(36, Math.floor(terminalColumns * 0.32)));
2693
- const detailWidth = Math.max(20, terminalColumns - labelWidth - 10);
2694
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: title }), searchable && (_jsxs(Text, { color: theme.muted, children: ["Filter: ", _jsx(Text, { color: theme.userMessageText, children: query || " " })] })), _jsx(Text, { color: theme.muted, children: hint }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [filtered.length === 0 && _jsx(Text, { color: theme.muted, children: emptyText }), visible.map((item, offset) => {
2695
- const actualIndex = start + offset;
2696
- const selected = actualIndex === selectedIndex;
2697
- return (_jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.accent : undefined, children: [selected ? "> " : " ", truncate(item.label, labelWidth)] }), _jsxs(Text, { color: theme.muted, children: [" ", truncate(item.detail, detailWidth)] })] }, `${item.action ?? "command"}-${item.value}`));
2698
- })] })] }));
2699
- }
2700
- const REWIND_SCOPE_ORDER = ["all", "chat", "code"];
2701
- const REWIND_SCOPE_LABEL = {
2702
- all: "chat + files",
2703
- chat: "chat only",
2704
- code: "files only",
2705
- };
2706
- function rewindCommand(turnIndex, scope) {
2707
- const base = `/rewind ${turnIndex + 1}`;
2708
- if (scope === "chat")
2709
- return `${base} --chat`;
2710
- if (scope === "code")
2711
- return `${base} --code`;
2712
- return base;
2713
- }
2714
- function cycleRewindScope(scope, direction) {
2715
- const index = REWIND_SCOPE_ORDER.indexOf(scope);
2716
- return REWIND_SCOPE_ORDER[(index + direction + REWIND_SCOPE_ORDER.length) % REWIND_SCOPE_ORDER.length];
2717
- }
2718
- function RewindPicker({ sessionManager, terminalColumns, terminalRows, onSelect, onCancel, }) {
2719
- const theme = useTheme();
2720
- const turns = useMemo(() => sessionManager.listUserTurns(), [sessionManager]);
2721
- const checkpoints = useMemo(() => sessionManager.getCheckpoints(), [sessionManager]);
2722
- const fileCounts = useMemo(() => {
2723
- const entries = checkpoints.listEntries();
2724
- const byTurn = new Map();
2725
- for (const entry of entries) {
2726
- const files = byTurn.get(entry.turn);
2727
- if (files)
2728
- files.add(entry.path);
2729
- else
2730
- byTurn.set(entry.turn, new Set([entry.path]));
2731
- }
2732
- return new Map(turns.map((turn) => [turn.id, byTurn.get(turn.id)?.size ?? 0]));
2733
- }, [checkpoints, turns]);
2734
- const [selectedIndex, setSelectedIndex] = useState(() => Math.max(0, turns.length - 1));
2735
- const [scope, setScope] = useState("all");
2736
- const maxVisible = Math.max(4, Math.min(10, terminalRows - 10));
2737
- useEffect(() => {
2738
- setSelectedIndex((current) => Math.min(Math.max(0, turns.length - 1), current));
2739
- }, [turns.length]);
2740
- useInput((input, key) => {
2741
- if (isKeyReleaseEvent(key))
2742
- return;
2743
- if (key.escape) {
2744
- onCancel();
2745
- return;
2746
- }
2747
- if (key.return) {
2748
- if (turns[selectedIndex])
2749
- onSelect(rewindCommand(selectedIndex, scope));
2750
- return;
2751
- }
2752
- if (key.upArrow) {
2753
- setSelectedIndex((index) => Math.max(0, index - 1));
2754
- return;
2755
- }
2756
- if (key.downArrow) {
2757
- setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + 1));
2758
- return;
2759
- }
2760
- if (key.pageUp) {
2761
- setSelectedIndex((index) => Math.max(0, index - maxVisible));
2762
- return;
2763
- }
2764
- if (key.pageDown) {
2765
- setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + maxVisible));
2766
- return;
2767
- }
2768
- if (key.tab || key.rightArrow || input === "l") {
2769
- setScope((current) => cycleRewindScope(current, 1));
2770
- return;
2771
- }
2772
- if (key.leftArrow || input === "h") {
2773
- setScope((current) => cycleRewindScope(current, -1));
2774
- }
2775
- });
2776
- const start = clampWindowStartForIndex(turns.length, selectedIndex, maxVisible);
2777
- const visibleTurns = turns.slice(start, start + maxVisible);
2778
- const previewWidth = Math.max(18, Math.min(76, terminalColumns - 34));
2779
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Rewind" }), _jsxs(Text, { color: theme.muted, children: ["Restore: ", _jsx(Text, { color: theme.accent, children: REWIND_SCOPE_LABEL[scope] }), " · ", turns.length, " point", turns.length === 1 ? "" : "s"] }), _jsx(Text, { color: theme.muted, children: "Up/Down choose \u00B7 Left/Right scope \u00B7 Enter rewind \u00B7 Esc cancel" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [turns.length === 0 && _jsx(Text, { color: theme.muted, children: "Nothing to rewind: no user messages in this session." }), visibleTurns.map((turn, offset) => {
2780
- const actualIndex = start + offset;
2781
- const isSelected = actualIndex === selectedIndex;
2782
- const touched = fileCounts.get(turn.id) ?? 0;
2783
- return (_jsx(RewindRow, { turn: turn, turnNumber: actualIndex + 1, selected: isSelected, fileCount: touched, previewWidth: previewWidth }, turn.id));
2784
- })] })] }));
2785
- }
2786
- function RewindRow({ turn, turnNumber, selected, fileCount, previewWidth, }) {
2787
- const theme = useTheme();
2788
- const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2789
- const fileNote = fileCount > 0 ? ` · ${fileCount} file${fileCount === 1 ? "" : "s"}` : "";
2790
- return (_jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.accent : undefined, children: [selected ? "> " : " ", String(turnNumber).padStart(2, " "), " ", time, " ", truncate(turn.preview, previewWidth)] }), _jsx(Text, { color: theme.muted, children: fileNote })] }));
2791
- }
2792
- function clampWindowStartForIndex(total, selectedIndex, maxVisible) {
2793
- if (total <= maxVisible)
2794
- return 0;
2795
- const half = Math.floor(maxVisible / 2);
2796
- let start = Math.max(0, selectedIndex - half);
2797
- if (start + maxVisible > total)
2798
- start = total - maxVisible;
2799
- return Math.max(0, start);
2800
- }
2801
- function StatsPanel({ panel, terminalColumns, terminalRows, onRangeChange, onCancel, }) {
2802
- const theme = useTheme();
2803
- const [scroll, setScroll] = useState(0);
2804
- const bodyWidth = Math.max(48, Math.min(92, terminalColumns - 6));
2805
- const lines = useMemo(() => formatStatsPanelBody(panel.bundle.ranges[panel.range], bodyWidth).split("\n"), [bodyWidth, panel.bundle, panel.range]);
2806
- const maxVisible = Math.max(5, Math.min(16, terminalRows - 10));
2807
- const maxScroll = Math.max(0, lines.length - maxVisible);
2808
- useEffect(() => {
2809
- setScroll(0);
2810
- }, [panel.range]);
2811
- useEffect(() => {
2812
- setScroll((current) => Math.min(current, maxScroll));
2813
- }, [maxScroll]);
2814
- useInput((input, key) => {
2815
- if (isKeyReleaseEvent(key))
2816
- return;
2817
- if (key.escape) {
2818
- onCancel();
2819
- return;
2820
- }
2821
- if (key.tab) {
2822
- onRangeChange(panel.range === "30d" ? "7d" : "30d");
2823
- return;
2824
- }
2825
- if (key.leftArrow || input === "h") {
2826
- onRangeChange("7d");
2827
- return;
2828
- }
2829
- if (key.rightArrow || input === "l") {
2830
- onRangeChange("30d");
2831
- return;
2832
- }
2833
- if (key.upArrow) {
2834
- setScroll((current) => Math.max(0, current - 1));
2835
- return;
2836
- }
2837
- if (key.downArrow) {
2838
- setScroll((current) => Math.min(maxScroll, current + 1));
2839
- return;
2840
- }
2841
- if (key.pageUp) {
2842
- setScroll((current) => Math.max(0, current - maxVisible));
2843
- return;
2844
- }
2845
- if (key.pageDown) {
2846
- setScroll((current) => Math.min(maxScroll, current + maxVisible));
2847
- }
2848
- });
2849
- const visible = lines.slice(scroll, scroll + maxVisible);
2850
- const generatedAt = panel.bundle.generatedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2851
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Stats" }), _jsxs(Text, { color: theme.muted, children: [rangeLabel(panel.range), " \u00B7 generated ", generatedAt] }), _jsx(Text, { color: theme.muted, children: "Left/Right range \u00B7 Up/Down scroll \u00B7 Tab toggle \u00B7 Esc close" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((line, index) => {
2852
- const key = `${scroll + index}-${line}`;
2853
- const heading = line === "Activity" || line === "Model usage" || line === "Summary";
2854
- return (_jsx(Text, { color: heading ? theme.accent : undefined, bold: heading, children: line || " " }, key));
2855
- }) }), maxScroll > 0 && (_jsxs(Text, { color: theme.muted, children: [scroll + 1, "-", Math.min(lines.length, scroll + maxVisible), " of ", lines.length] }))] }));
2856
- }
2857
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2858
- const GENERIC_PHRASES = [
2859
- "mapping the workspace",
2860
- "reading the room",
2861
- "following the threads",
2862
- "connecting the pieces",
2863
- "sorting the context",
2864
- "scanning the structure",
2865
- "shaping the next step",
2866
- "gathering signal",
2867
- "checking the edges",
2868
- "lining up the answer",
2869
- "tracing the flow",
2870
- "building the picture",
2871
- "walking the graph",
2872
- "collecting the clues",
2873
- "framing the problem",
2874
- "locating the source",
2875
- "resolving the shape",
2876
- "untangling the state",
2877
- "comparing the paths",
2878
- "narrowing the target",
2879
- "tracking the changes",
2880
- "reading the patterns",
2881
- "weighing the options",
2882
- "assembling the context",
2883
- "following the signal",
2884
- "checking the assumptions",
2885
- "aligning the details",
2886
- "testing the shape",
2887
- "pulling the thread",
2888
- "cleaning the edges",
2889
- "refining the draft",
2890
- "verifying the route",
2891
- "making sense of it",
2892
- "looking for leverage",
2893
- "stitching the answer",
2894
- "holding the thread",
2895
- "distilling the noise",
2896
- "finding the seam",
2897
- "reading between the lines",
2898
- "preparing the response",
2899
- ];
2900
- const TOOL_TARGET_PHRASES = {
2901
- read: "reading files",
2902
- write: "writing changes",
2903
- edit: "patching files",
2904
- grep: "searching the codebase",
2905
- glob: "scanning paths",
2906
- ls: "listing directories",
2907
- bash: "running command",
2908
- web_search: "searching the web",
2909
- web_fetch: "fetching a page",
2910
- task: "spawning subagent",
2911
- };
2912
- function formatTokensApprox(chars) {
2913
- const tokens = Math.max(0, Math.round(chars / 4));
2914
- if (tokens < 1000)
2915
- return `${tokens}`;
2916
- if (tokens < 10000)
2917
- return `${(tokens / 1000).toFixed(1)}k`;
2918
- return `${Math.round(tokens / 1000)}k`;
2919
- }
2920
- function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, pendingSteerCount = 0, queuedCount = 0, }) {
2921
- void nowTick;
2922
- const theme = useTheme();
2923
- const [frameIndex, setFrameIndex] = useState(0);
2924
- const [idlePhrase, setIdlePhrase] = useState(() => GENERIC_PHRASES[0]);
2925
- // Frame timer is independent of the agent state — keeps animation smooth.
2926
- useEffect(() => {
2927
- const t = setInterval(() => {
2928
- setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
2929
- }, 100);
2930
- return () => clearInterval(t);
2931
- }, []);
2932
- // Determine state: active tool > streaming text > streaming reasoning > idle
2933
- const activeTool = [...tools].reverse().find((t) => !t.result);
2934
- const state = activeTool
2935
- ? "tool"
2936
- : hasStreamingText
2937
- ? "text"
2938
- : hasStreamingReasoning
2939
- ? "reasoning"
2940
- : "idle";
2941
- // Rotate idle phrases on a slower cadence; only matters in the idle state.
2942
- useEffect(() => {
2943
- if (state !== "idle")
2944
- return;
2945
- const t = setInterval(() => {
2946
- setIdlePhrase((current) => {
2947
- const candidates = GENERIC_PHRASES.filter((item) => item !== current);
2948
- return candidates[Math.floor(Math.random() * candidates.length)] || current;
2949
- });
2950
- }, 1500);
2951
- return () => clearInterval(t);
2952
- }, [state]);
2953
- let phrase;
2954
- if (state === "tool" && activeTool) {
2955
- phrase =
2956
- TOOL_TARGET_PHRASES[activeTool.name] || `running ${activeTool.name}`;
2957
- }
2958
- else if (state === "text") {
2959
- phrase = "writing the response";
2960
- }
2961
- else if (state === "reasoning") {
2962
- phrase = "working through the request";
2963
- }
2964
- else {
2965
- phrase = idlePhrase;
2966
- }
2967
- const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
2968
- const hintParts = [];
2969
- if (tokenText)
2970
- hintParts.push(tokenText);
2971
- if (pendingSteerCount > 0)
2972
- hintParts.push(`${pendingSteerCount} pending steer${pendingSteerCount === 1 ? "" : "s"}`);
2973
- if (queuedCount > 0)
2974
- hintParts.push(`${queuedCount} queued`);
2975
- hintParts.push("enter steer", "tab queue", "esc stop");
2976
- return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frameIndex] }), _jsxs(Text, { color: theme.muted, children: [" ", phrase, " "] }), _jsxs(Text, { color: theme.muted, dimColor: true, children: ["(", hintParts.join(" · "), ")"] })] }));
2977
- }