@bubblebrain-ai/bubble 0.0.48 → 0.0.50

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 (51) hide show
  1. package/README.md +17 -0
  2. package/dist/agent/abort-errors.d.ts +4 -0
  3. package/dist/agent/abort-errors.js +30 -0
  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/transcript-helpers.d.ts +20 -0
  9. package/dist/agent/transcript-helpers.js +62 -0
  10. package/dist/agent/update-queue.d.ts +13 -0
  11. package/dist/agent/update-queue.js +69 -0
  12. package/dist/agent.d.ts +33 -16
  13. package/dist/agent.js +90 -300
  14. package/dist/external-runtime/grok-runtime.js +1 -1
  15. package/dist/feishu/agent-host/run-driver.js +1 -1
  16. package/dist/lsp/service.js +2 -2
  17. package/dist/model-catalog.d.ts +13 -0
  18. package/dist/model-catalog.js +52 -8
  19. package/dist/permissions/settings.d.ts +0 -1
  20. package/dist/permissions/settings.js +0 -2
  21. package/dist/provider-grok.d.ts +16 -18
  22. package/dist/provider-grok.js +28 -20
  23. package/dist/provider-registry.d.ts +9 -0
  24. package/dist/provider-registry.js +109 -6
  25. package/dist/provider-transform.js +7 -8
  26. package/dist/provider.js +11 -0
  27. package/dist/session-log.js +1 -0
  28. package/dist/slash-commands/commands.js +13 -9
  29. package/dist/tools/grep.js +1 -1
  30. package/dist/tui-ink/app-helpers.d.ts +43 -0
  31. package/dist/tui-ink/app-helpers.js +150 -0
  32. package/dist/tui-ink/app.d.ts +4 -11
  33. package/dist/tui-ink/app.js +104 -635
  34. package/dist/tui-ink/command-palette.d.ts +32 -0
  35. package/dist/tui-ink/command-palette.js +158 -0
  36. package/dist/tui-ink/display-history.d.ts +2 -0
  37. package/dist/tui-ink/display-reconstruct.d.ts +3 -0
  38. package/dist/tui-ink/display-reconstruct.js +82 -0
  39. package/dist/tui-ink/footer.d.ts +14 -3
  40. package/dist/tui-ink/footer.js +19 -5
  41. package/dist/tui-ink/rewind-picker.d.ts +8 -0
  42. package/dist/tui-ink/rewind-picker.js +102 -0
  43. package/dist/tui-ink/session-picker.js +1 -1
  44. package/dist/tui-ink/stats-panel.d.ts +11 -0
  45. package/dist/tui-ink/stats-panel.js +65 -0
  46. package/dist/tui-ink/waiting-indicator.d.ts +12 -0
  47. package/dist/tui-ink/waiting-indicator.js +129 -0
  48. package/dist/tui-ink/wordmark.d.ts +8 -0
  49. package/dist/tui-ink/wordmark.js +103 -0
  50. package/dist/types.d.ts +6 -0
  51. package/package.json +1 -1
@@ -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";
@@ -22,6 +21,7 @@ import { SkillRegistry } from "../skills/registry.js";
22
21
  import { parseSkillInvocation } from "../skills/invocation.js";
23
22
  import { useTerminalSize } from "./use-terminal-size.js";
24
23
  import { WelcomeBanner, shouldShowWelcomeBanner } from "./welcome.js";
24
+ import { BubbleCodeWordmark } from "./wordmark.js";
25
25
  import { expandAtMentions } from "./file-mentions.js";
26
26
  import { TodosPanel } from "./todos.js";
27
27
  import { CompactionProgressCard } from "./compaction-progress.js";
@@ -48,213 +48,25 @@ import { tokenUsageTotal } from "../goal/usage.js";
48
48
  import { formatInternalContextBlock, isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
49
49
  import { MAX_ACTIVE_LOOPS, decideLoopFiring, formatInterval, formatLoopList, parseLoopCommand, } from "../loop/engine.js";
50
50
  import { TaskWakeCoalescer, findDanglingTaskStarts, formatTaskWakeSummary, isPidAlive, shouldFireTaskWake, taskEligibleForWake, } from "../tasks/wake.js";
51
- const TASK_WAKE_DEBOUNCE_MS = 2000;
52
- import { collectUsageStatsBundle, formatStatsPanelBody, rangeLabel } from "../stats/usage.js";
51
+ import { STREAMING_FLUSH_INTERVAL_MS, TASK_WAKE_DEBOUNCE_MS, buildTips, formatContextUsageLabel, friendlyCwd, parsePartialArgs, sessionBasename, slashResultNoticeKind, taskRowSummary, truncate, withMessageKey, } from "./app-helpers.js";
52
+ import { reconstructDisplayMessages } from "./display-reconstruct.js";
53
+ export { reconstructDisplayMessages } from "./display-reconstruct.js";
54
+ import { INK_LOCAL_SLASH_COMMANDS, CommandPalette, McpReconnectPicker, buildCommandPaletteItems, buildMcpReconnectItems, } from "./command-palette.js";
55
+ export { INK_LOCAL_SLASH_COMMANDS } from "./command-palette.js";
56
+ import { RewindPicker } from "./rewind-picker.js";
57
+ import { StatsPanel } from "./stats-panel.js";
58
+ import { WaitingIndicator } from "./waiting-indicator.js";
59
+ import { collectUsageStatsBundle } from "../stats/usage.js";
53
60
  import { GrokRuntimeError } from "../external-runtime/grok-errors.js";
54
- import { GROK_LOCAL_SLASH_COMMANDS, classifyGrokInput, isGrokLocalSlashCommand, } from "../external-runtime/grok-input-policy.js";
61
+ import { GROK_LOCAL_SLASH_COMMANDS, classifyGrokInput, } from "../external-runtime/grok-input-policy.js";
55
62
  import { GROK_SUBSCRIPTION_PROVIDER_ID, isGrokSubscriptionProviderId, withGrokSubscriptionProvider, } from "../external-runtime/grok-provider.js";
56
63
  import { classifyExternalRuntimeBinding, stopExternalRuntimeForSessionSwitch, } from "../external-runtime/session-policy.js";
57
- import os from "node:os";
58
64
  import { rmSync } from "node:fs";
59
- function buildTips(agent, registry, grokActive = false) {
60
- const tips = [];
61
- if (grokActive) {
62
- return [
63
- "Grok Subscription is active with workspace tools and Bubble approvals",
64
- "Use /model to choose the Grok model and reasoning effort",
65
- "Use /logout grok to return to a fresh native session",
66
- ];
67
- }
68
- const hasProvider = registry.getEnabled().length > 0;
69
- if (!hasProvider) {
70
- tips.push("Run /login or /provider --add to configure a model");
71
- }
72
- else if (agent.model) {
73
- tips.push(`Ready with ${displayModel(agent.model)}`);
74
- }
75
- else {
76
- tips.push("Run /model to pick a model");
77
- }
78
- tips.push("Type @ to reference a file");
79
- tips.push("Type / for commands and skills");
80
- return tips;
81
- }
82
- /**
83
- * Slash-command results arrive as plain strings; recognize the /model
84
- * confirmations by shape so they render as accent-colored UI notices — the
85
- * same treatment the picker path applies — instead of assistant prose.
86
- */
87
- function slashResultNoticeKind(result) {
88
- return /^(Grok m|M)odel switched to /.test(result) ? "ui_notice" : undefined;
89
- }
65
+ import { execFile } from "node:child_process";
90
66
  // Canonical implementation lives in the sanitizer module so non-TUI surfaces
91
67
  // (session pickers, desktop host) share it; re-exported here for existing users.
92
68
  export { isInternalBlockOnlyContent };
93
- /** Status-row summary for running background tasks (design §2.5). */
94
- function taskRowSummary(tasks, nowTick) {
95
- const first = tasks[0];
96
- const label = first.description?.trim() || first.command.replace(/\s+/g, " ").slice(0, 32);
97
- const elapsed = Math.max(0, Math.round((nowTick - first.startedAt) / 1000));
98
- return `${label} ${elapsed}s`;
99
- }
100
- function friendlyCwd(cwd) {
101
- const home = os.homedir();
102
- if (cwd === home)
103
- return "~";
104
- if (cwd.startsWith(home + "/"))
105
- return "~" + cwd.slice(home.length);
106
- return cwd;
107
- }
108
- function sessionBasename(sessionFile) {
109
- if (!sessionFile)
110
- return undefined;
111
- const base = sessionFile.split("/").pop() ?? sessionFile;
112
- return base.replace(/\.jsonl$/, "");
113
- }
114
- function truncate(value, max) {
115
- if (value.length <= max)
116
- return value;
117
- return `${value.slice(0, Math.max(0, max - 1))}…`;
118
- }
119
- export function reconstructDisplayMessages(agentMessages) {
120
- const result = [];
121
- for (const m of agentMessages) {
122
- if (m.role === "system" || m.role === "tool")
123
- continue;
124
- if (m.role === "user") {
125
- if (m.isMeta)
126
- continue; // <system-reminder> injections are not user-visible
127
- // Harness-initiated kicks (goal continuations, task wakes) persist as
128
- // user-role messages wrapped in internal blocks so the model keeps
129
- // them across resume — but they were hidden live and must stay hidden
130
- // when the transcript is rebuilt.
131
- if (isInternalBlockOnlyContent(m.content))
132
- continue;
133
- result.push({
134
- key: nextDisplayMessageKey("user"),
135
- role: "user",
136
- content: typeof m.content === "string" ? m.content : "(multimedia)",
137
- });
138
- }
139
- else if (m.role === "assistant") {
140
- const toolCalls = [];
141
- if (m.toolCalls) {
142
- for (const tc of m.toolCalls) {
143
- let args = {};
144
- try {
145
- args = JSON.parse(tc.arguments || "{}");
146
- }
147
- catch {
148
- args = {};
149
- }
150
- const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
151
- if (isHiddenToolMetadata(toolResult ? toolResult.metadata : undefined))
152
- continue;
153
- toolCalls.push({
154
- id: tc.id,
155
- name: tc.name,
156
- args,
157
- result: toolResult ? toolResult.content : undefined,
158
- isError: toolResult ? toolResult.content?.startsWith?.("Error:") : false,
159
- metadata: toolResult ? toolResult.metadata : undefined,
160
- });
161
- }
162
- }
163
- // An aborted assistant message carries the model-facing interruption
164
- // note in its content. Render only what the assistant actually said
165
- // (partial streamed text, if any) plus a dedicated interrupt row —
166
- // never the note itself, which reads like a leaked system prompt.
167
- const interrupted = m.error?.aborted === true;
168
- const content = interrupted
169
- ? stripInterruptedAssistantMarker(m.content, INTERRUPTED_ASSISTANT_CONTENT)
170
- : m.content;
171
- if (content || m.reasoning || toolCalls.length > 0) {
172
- result.push({
173
- key: nextDisplayMessageKey("asst"),
174
- role: "assistant",
175
- content,
176
- reasoning: m.reasoning || undefined,
177
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
178
- });
179
- }
180
- if (interrupted) {
181
- result.push({
182
- key: nextDisplayMessageKey("asst"),
183
- role: "assistant",
184
- content: "Interrupted by user",
185
- syntheticKind: "ui_interrupt",
186
- });
187
- }
188
- }
189
- }
190
- return result;
191
- }
192
- /**
193
- * Streaming tool arguments arrive as an incomplete JSON buffer. We can't
194
- * JSON.parse() until the closing brace lands, but the user wants to see the
195
- * short identifying fields (path, command, …) as soon as the model emits
196
- * them so the tool row header reflects what's happening.
197
- *
198
- * Intentionally limited to short, single-line fields. Long fields like
199
- * `content` are *not* surfaced live: rendering thousands of partial lines
200
- * per delta floods the terminal and the partial value can break around
201
- * unescaped sequences. The final value lands when the tool actually
202
- * executes and tool_start delivers canonical args.
203
- */
204
- function parsePartialArgs(buffer, previous) {
205
- // If the buffer is now valid JSON, prefer the real parse.
206
- try {
207
- const parsed = JSON.parse(buffer);
208
- if (parsed && typeof parsed === "object")
209
- return parsed;
210
- }
211
- catch {
212
- // fall through to partial extraction below
213
- }
214
- const result = { ...previous };
215
- const FIELDS = ["path", "command", "pattern", "url", "query"];
216
- for (const field of FIELDS) {
217
- // Match a complete-looking quoted string. Requires a closing quote so we
218
- // don't surface half-typed paths that may still change as bytes arrive.
219
- const match = buffer.match(new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`));
220
- if (match) {
221
- const raw = match[1] ?? "";
222
- result[field] = raw
223
- .replace(/\\n/g, "\n")
224
- .replace(/\\t/g, "\t")
225
- .replace(/\\"/g, '"')
226
- .replace(/\\\\/g, "\\");
227
- }
228
- }
229
- return result;
230
- }
231
- /**
232
- * Coerce a freshly-constructed DisplayMessage into one that carries a stable
233
- * `key`. Centralizes the safety net so callers don't have to remember to call
234
- * nextDisplayMessageKey on every push.
235
- */
236
- function withMessageKey(message) {
237
- if (message.key)
238
- return message;
239
- const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
240
- return { ...message, key: nextDisplayMessageKey(prefix) };
241
- }
242
- // Batch streaming text deltas before committing them to React state. Without
243
- // <Static>, every commit re-renders the full-screen frame; per-token commits
244
- // would make Yoga re-lay-out the transcript for every few bytes of output.
245
- // 40ms keeps perceived latency invisible while capping layout work at 25fps.
246
- const STREAMING_FLUSH_INTERVAL_MS = 40;
247
- export const INK_LOCAL_SLASH_COMMANDS = [
248
- {
249
- name: "goal",
250
- description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
251
- },
252
- {
253
- name: "loop",
254
- description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])",
255
- },
256
- ];
257
- 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 }) {
69
+ 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 }) {
258
70
  const [sessionManager, setSessionManager] = useState(initialSessionManager);
259
71
  const [externalRuntimeBinding, setExternalRuntimeBinding] = useState(() => initialSessionManager?.getMetadata().externalRuntime);
260
72
  const sessionManagerRef = useRef(sessionManager);
@@ -328,21 +140,53 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
328
140
  }, [subagentMembers.length, subagentEntryFocused]);
329
141
  // Live progress for a manual `/compact` run (null when not compacting).
330
142
  const [compaction, setCompaction] = useState(null);
331
- // Normalize agent.thinking against the current model's supported levels so the
332
- // banner displays the *effective* level, not a stale user-config value like
333
- // "xhigh" when switching to a model that only supports ["high","max","off"].
334
- const [thinkingLevel, setThinkingLevel] = useState(() => {
335
- const modelParts = agent.model.includes(":")
336
- ? agent.model.split(":")
337
- : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
338
- const providerId = modelParts[0];
339
- const modelId = modelParts.slice(1).join(":");
340
- const availableLevels = getAvailableThinkingLevels(providerId, modelId);
341
- return normalizeThinkingLevel(agent.thinking, availableLevels);
342
- });
143
+ // The reasoning level is persisted on the agent (agent.thinking); display it
144
+ // directly. switchAgentModel re-normalizes on explicit model switches, so the
145
+ // initial value needs no clamping against the (possibly not-yet-discovered)
146
+ // model ladder.
147
+ const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
343
148
  const [permissionMode, setPermissionMode] = useState(agent.mode);
344
149
  const [todos, setTodos] = useState(() => agent.getTodos());
345
150
  const [goalLine, setGoalLine] = useState("");
151
+ const [branch, setBranch] = useState(undefined);
152
+ const [contextUsage, setContextUsage] = useState("");
153
+ useEffect(() => {
154
+ let cancelled = false;
155
+ execFile("git", ["-C", args.cwd, "branch", "--show-current"], { timeout: 3000 }, (error, stdout) => {
156
+ if (cancelled)
157
+ return;
158
+ if (error)
159
+ return;
160
+ const name = stdout.trim();
161
+ setBranch(name || undefined);
162
+ });
163
+ return () => { cancelled = true; };
164
+ }, [args.cwd]);
165
+ // Push-based sync: the agent notifies on every resident-context mutation
166
+ // (message rewrites from /clear /compact /rewind and session switches,
167
+ // per-message appends during runs, model/provider switches), so this single
168
+ // subscription replaces the per-path manual refreshes that were easy to
169
+ // forget (the /clear footer bug). Initial read covers agents mounted with
170
+ // pre-existing history.
171
+ //
172
+ // Notifications are trailing-edge debounced: a snapshot run is O(all
173
+ // resident messages) (tiktoken providers measured ~225ms at 400 messages),
174
+ // and a tool-dense turn appends 5-15 messages synchronously — recomputing
175
+ // per append would block the run loop 1-3s. The footer is a human-facing
176
+ // readout; one recomputation 150ms after the last append is indistinguishable.
177
+ useEffect(() => {
178
+ let timer;
179
+ const update = () => setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
180
+ update();
181
+ const off = agent.onContextChanged(() => {
182
+ clearTimeout(timer);
183
+ timer = setTimeout(update, 150);
184
+ });
185
+ return () => {
186
+ clearTimeout(timer);
187
+ off();
188
+ };
189
+ }, [agent]);
346
190
  const [currentUpdateNotice, setCurrentUpdateNotice] = useState(updateNotice);
347
191
  const [pendingPlan, setPendingPlan] = useState(null);
348
192
  const [pendingApproval, setPendingApproval] = useState(null);
@@ -371,12 +215,19 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
371
215
  // MessageList so Ink discards its already-printed rows and re-prints the
372
216
  // rebuilt list onto a freshly-cleared screen instead of appending duplicates.
373
217
  const [staticGeneration, setStaticGeneration] = useState(0);
218
+ // The clear must go through Ink's stdout hook, not a raw process.stdout
219
+ // write: Ink replays its last dynamic frame after any external write
220
+ // (restoreLastOutput), which is the only repaint guarantee when the
221
+ // generation bump produces no new output — with zero settled rows (fresh
222
+ // welcome screen) <Static> emits nothing and the dynamic frame diffs equal,
223
+ // so Ink skips the write and a raw clear would leave the screen blank.
224
+ const { write: writeThroughInk } = useStdout();
374
225
  const reprintTranscript = useCallback(() => {
375
226
  if (process.stdout.isTTY) {
376
- process.stdout.write("\x1b[0m\x1b[2J\x1b[3J\x1b[H");
227
+ writeThroughInk("\x1b[0m\x1b[2J\x1b[3J\x1b[H");
377
228
  }
378
229
  setStaticGeneration((generation) => generation + 1);
379
- }, []);
230
+ }, [writeThroughInk]);
380
231
  // Steer/queue while the agent runs:
381
232
  // Enter steers the current run via the agent's input controller; Tab (or an
382
233
  // ineligible input) queues for the next turn. Both render placeholder user
@@ -1066,6 +917,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1066
917
  setThinkingLevel,
1067
918
  sessionManager: targetSessionManager,
1068
919
  });
920
+ // Persist the resolved effort alongside the model so a fresh session
921
+ // restores both. Without this, the picker path only remembered the model
922
+ // and new sessions fell back to a stale config.json defaultThinkingLevel
923
+ // (e.g. picking glm-5.3 + max still restored "high" on next launch).
924
+ userConfig.setDefaultThinkingLevel(nextThinkingLevel);
1069
925
  // Binary thinking toggles and thinking-only models use internal level
1070
926
  // placeholders; do not present those placeholders as real effort grades.
1071
927
  const decodedModel = decodeModel(model);
@@ -1134,8 +990,10 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1134
990
  transitionToNative,
1135
991
  onExternalRuntimeChange: refreshExternalRuntimeBinding,
1136
992
  });
1137
- if (handled && result)
1138
- addMessage("assistant", result, slashResultNoticeKind(result));
993
+ if (handled) {
994
+ if (result)
995
+ addMessage("assistant", result, slashResultNoticeKind(result));
996
+ }
1139
997
  }, [
1140
998
  addMessage,
1141
999
  agent,
@@ -1415,6 +1273,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1415
1273
  let runErrored = false;
1416
1274
  const toolCalls = [];
1417
1275
  const assistantParts = [];
1276
+ let assistantSystemFingerprint;
1418
1277
  // Entries whose members all reached a final status are now covered by
1419
1278
  // the settled transcript; drop them so the accumulator stays bounded
1420
1279
  // across a long session. Running entries survive — for a workflow
@@ -1480,6 +1339,9 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1480
1339
  if (taskElapsedMs !== undefined && Number.isFinite(taskElapsedMs) && taskElapsedMs > 0) {
1481
1340
  msg.taskElapsedMs = taskElapsedMs;
1482
1341
  }
1342
+ if (assistantSystemFingerprint) {
1343
+ msg.systemFingerprint = assistantSystemFingerprint;
1344
+ }
1483
1345
  updateDisplayMessages((prev) => [...prev, msg]);
1484
1346
  if (runExternalRuntime) {
1485
1347
  runSessionManager.appendMessage({
@@ -1502,6 +1364,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1502
1364
  setStreamingParts([]);
1503
1365
  assistantContent = "";
1504
1366
  assistantReasoning = "";
1367
+ assistantSystemFingerprint = undefined;
1505
1368
  toolCalls.length = 0;
1506
1369
  assistantParts.length = 0;
1507
1370
  };
@@ -1719,6 +1582,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1719
1582
  goalRunUsageReported = true;
1720
1583
  goalRunTokens += tokenUsageTotal(event.usage);
1721
1584
  }
1585
+ assistantSystemFingerprint = event.systemFingerprint;
1722
1586
  if (event.willContinue) {
1723
1587
  commitAssistantMessage();
1724
1588
  clearAssistantStream();
@@ -2375,7 +2239,16 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2375
2239
  const bannerThinkingLabel = grokSessionBound
2376
2240
  ? externalRuntimeBinding?.reasoningEffort
2377
2241
  : !externalSessionBound && showThinkingLabel ? thinkingLevel : undefined;
2378
- const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: bannerTips, updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: bannerProviderId, modelId: bannerModelId, modelLabel: bannerModelLabel, thinkingLabel: bannerThinkingLabel })) : null;
2242
+ // Footer surfaces the graded effort level too (grok/DeepSeek/OpenAI ladders),
2243
+ // but skips on/off toggles and thinking-only placeholders ("medium").
2244
+ const footerThinkingLabel = !externalSessionBound
2245
+ && thinkingLevel
2246
+ && thinkingLevel !== "off"
2247
+ && !isThinkingToggle
2248
+ && !isThinkingOnly
2249
+ ? thinkingLevel
2250
+ : undefined;
2251
+ const welcomeBannerNode = showWelcome ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(BubbleCodeWordmark, { width: terminalColumns }), _jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: bannerTips, updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: bannerProviderId, modelId: bannerModelId, modelLabel: bannerModelLabel, thinkingLabel: bannerThinkingLabel })] })) : null;
2379
2252
  const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry, externalSessionBound), [externalSessionBound, safeSkillRegistry]);
2380
2253
  const mcpReconnectItems = useMemo(() => buildMcpReconnectItems(mcpManager), [mcpManager]);
2381
2254
  // No fixed-height frame: settled rows flow into the terminal's native
@@ -2502,418 +2375,14 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2502
2375
  ? `Grok Subscription${externalRuntimeBinding?.modelId ? ` · ${externalRuntimeBinding.modelId}` : ""}${externalRuntimeBinding?.reasoningEffort && externalRuntimeBinding.reasoningEffort !== "off" ? ` · ${externalRuntimeBinding.reasoningEffort}` : ""} · workspace`
2503
2376
  : "Unsupported external runtime · recovery-only")
2504
2377
  : undefined,
2378
+ cwd: friendlyCwd(args.cwd),
2379
+ branch,
2380
+ model: externalSessionBound
2381
+ ? undefined
2382
+ : (agent.model
2383
+ ? `${displayModel(agent.model)}${footerThinkingLabel ? ` · ${footerThinkingLabel}` : ""}`
2384
+ : undefined),
2385
+ sessionTitle: externalSessionBound ? undefined : (sessionManager?.getMetadata().title?.trim() || undefined),
2386
+ contextUsage: externalSessionBound ? undefined : (contextUsage || undefined),
2505
2387
  }) }) }))] }) }));
2506
2388
  }
2507
- function buildCommandPaletteItems(skillRegistry, grokSessionBound = false) {
2508
- const items = new Map();
2509
- const add = (item) => {
2510
- const key = `${item.action ?? "command"}:${item.value}`;
2511
- if (!items.has(key))
2512
- items.set(key, item);
2513
- };
2514
- if (!grokSessionBound) {
2515
- for (const command of INK_LOCAL_SLASH_COMMANDS) {
2516
- add({
2517
- label: `/${command.name}`,
2518
- detail: command.description,
2519
- value: command.name,
2520
- command: `/${command.name}`,
2521
- });
2522
- }
2523
- }
2524
- for (const command of slashRegistry.list()) {
2525
- if (grokSessionBound && !isGrokLocalSlashCommand(command.name))
2526
- continue;
2527
- const source = command.source === "mcp" ? " :mcp" : "";
2528
- const sourceLabel = command.sourceLabel ? `[${command.sourceLabel}] ` : "";
2529
- add({
2530
- label: `/${command.name}${source}`,
2531
- detail: `${sourceLabel}${command.description}`,
2532
- value: command.name,
2533
- command: `/${command.name}`,
2534
- });
2535
- }
2536
- if (!grokSessionBound) {
2537
- for (const skill of skillRegistry.summaries()) {
2538
- add({
2539
- label: `/${skill.name} :skill`,
2540
- detail: `[${skill.source}] ${skill.description}`,
2541
- value: skill.name,
2542
- command: `/${skill.name}`,
2543
- action: "insert-skill",
2544
- });
2545
- }
2546
- }
2547
- return [...items.values()];
2548
- }
2549
- function buildMcpReconnectItems(mcpManager) {
2550
- return (mcpManager?.getStates() ?? []).map((state) => {
2551
- let detail;
2552
- if (state.status.kind === "connected") {
2553
- const tools = state.status.tools.length;
2554
- const prompts = state.status.prompts.length;
2555
- detail = `connected · ${tools} tool${tools === 1 ? "" : "s"} · ${prompts} prompt${prompts === 1 ? "" : "s"}`;
2556
- }
2557
- else if (state.status.kind === "failed") {
2558
- detail = `failed · ${state.status.error}`;
2559
- }
2560
- else {
2561
- detail = "disabled";
2562
- }
2563
- return {
2564
- label: state.name,
2565
- detail,
2566
- value: state.name,
2567
- command: `/mcp reconnect ${state.name}`,
2568
- };
2569
- });
2570
- }
2571
- function CommandPalette({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
2572
- 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 }));
2573
- }
2574
- function McpReconnectPicker({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
2575
- 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 }));
2576
- }
2577
- function PalettePicker({ title, hint, emptyText, items, terminalColumns, terminalRows, searchable = false, reconnectAlias = false, onSelect, onCancel, }) {
2578
- const theme = useTheme();
2579
- const [query, setQuery] = useState("");
2580
- const [selectedIndex, setSelectedIndex] = useState(0);
2581
- const maxVisible = Math.max(5, Math.min(12, terminalRows - 10));
2582
- const filtered = useMemo(() => {
2583
- const needle = query.trim().toLowerCase();
2584
- if (!needle)
2585
- return items;
2586
- return items.filter((item) => item.label.toLowerCase().includes(needle) ||
2587
- item.detail.toLowerCase().includes(needle) ||
2588
- item.value.toLowerCase().includes(needle));
2589
- }, [items, query]);
2590
- useEffect(() => {
2591
- setSelectedIndex((current) => Math.min(Math.max(0, filtered.length - 1), current));
2592
- }, [filtered.length]);
2593
- useInput((input, key) => {
2594
- if (isKeyReleaseEvent(key))
2595
- return;
2596
- if (key.escape) {
2597
- onCancel();
2598
- return;
2599
- }
2600
- if (key.return || (reconnectAlias && input.toLowerCase() === "r")) {
2601
- const item = filtered[selectedIndex];
2602
- if (item)
2603
- onSelect(item);
2604
- return;
2605
- }
2606
- if (key.upArrow) {
2607
- setSelectedIndex((index) => Math.max(0, index - 1));
2608
- return;
2609
- }
2610
- if (key.downArrow) {
2611
- setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + 1));
2612
- return;
2613
- }
2614
- if (key.pageUp) {
2615
- setSelectedIndex((index) => Math.max(0, index - maxVisible));
2616
- return;
2617
- }
2618
- if (key.pageDown) {
2619
- setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + maxVisible));
2620
- return;
2621
- }
2622
- if (!searchable)
2623
- return;
2624
- if (key.backspace || key.delete) {
2625
- setQuery((current) => current.slice(0, -1));
2626
- return;
2627
- }
2628
- if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
2629
- setQuery((current) => current + input);
2630
- }
2631
- });
2632
- const start = clampWindowStartForIndex(filtered.length, selectedIndex, maxVisible);
2633
- const visible = filtered.slice(start, start + maxVisible);
2634
- const labelWidth = Math.max(18, Math.min(36, Math.floor(terminalColumns * 0.32)));
2635
- const detailWidth = Math.max(20, terminalColumns - labelWidth - 10);
2636
- 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) => {
2637
- const actualIndex = start + offset;
2638
- const selected = actualIndex === selectedIndex;
2639
- 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}`));
2640
- })] })] }));
2641
- }
2642
- const REWIND_SCOPE_ORDER = ["all", "chat", "code"];
2643
- const REWIND_SCOPE_LABEL = {
2644
- all: "chat + files",
2645
- chat: "chat only",
2646
- code: "files only",
2647
- };
2648
- function rewindCommand(turnIndex, scope) {
2649
- const base = `/rewind ${turnIndex + 1}`;
2650
- if (scope === "chat")
2651
- return `${base} --chat`;
2652
- if (scope === "code")
2653
- return `${base} --code`;
2654
- return base;
2655
- }
2656
- function cycleRewindScope(scope, direction) {
2657
- const index = REWIND_SCOPE_ORDER.indexOf(scope);
2658
- return REWIND_SCOPE_ORDER[(index + direction + REWIND_SCOPE_ORDER.length) % REWIND_SCOPE_ORDER.length];
2659
- }
2660
- function RewindPicker({ sessionManager, terminalColumns, terminalRows, onSelect, onCancel, }) {
2661
- const theme = useTheme();
2662
- const turns = useMemo(() => sessionManager.listUserTurns(), [sessionManager]);
2663
- const checkpoints = useMemo(() => sessionManager.getCheckpoints(), [sessionManager]);
2664
- const fileCounts = useMemo(() => {
2665
- const entries = checkpoints.listEntries();
2666
- const byTurn = new Map();
2667
- for (const entry of entries) {
2668
- const files = byTurn.get(entry.turn);
2669
- if (files)
2670
- files.add(entry.path);
2671
- else
2672
- byTurn.set(entry.turn, new Set([entry.path]));
2673
- }
2674
- return new Map(turns.map((turn) => [turn.id, byTurn.get(turn.id)?.size ?? 0]));
2675
- }, [checkpoints, turns]);
2676
- const [selectedIndex, setSelectedIndex] = useState(() => Math.max(0, turns.length - 1));
2677
- const [scope, setScope] = useState("all");
2678
- const maxVisible = Math.max(4, Math.min(10, terminalRows - 10));
2679
- useEffect(() => {
2680
- setSelectedIndex((current) => Math.min(Math.max(0, turns.length - 1), current));
2681
- }, [turns.length]);
2682
- useInput((input, key) => {
2683
- if (isKeyReleaseEvent(key))
2684
- return;
2685
- if (key.escape) {
2686
- onCancel();
2687
- return;
2688
- }
2689
- if (key.return) {
2690
- if (turns[selectedIndex])
2691
- onSelect(rewindCommand(selectedIndex, scope));
2692
- return;
2693
- }
2694
- if (key.upArrow) {
2695
- setSelectedIndex((index) => Math.max(0, index - 1));
2696
- return;
2697
- }
2698
- if (key.downArrow) {
2699
- setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + 1));
2700
- return;
2701
- }
2702
- if (key.pageUp) {
2703
- setSelectedIndex((index) => Math.max(0, index - maxVisible));
2704
- return;
2705
- }
2706
- if (key.pageDown) {
2707
- setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + maxVisible));
2708
- return;
2709
- }
2710
- if (key.tab || key.rightArrow || input === "l") {
2711
- setScope((current) => cycleRewindScope(current, 1));
2712
- return;
2713
- }
2714
- if (key.leftArrow || input === "h") {
2715
- setScope((current) => cycleRewindScope(current, -1));
2716
- }
2717
- });
2718
- const start = clampWindowStartForIndex(turns.length, selectedIndex, maxVisible);
2719
- const visibleTurns = turns.slice(start, start + maxVisible);
2720
- const previewWidth = Math.max(18, Math.min(76, terminalColumns - 34));
2721
- 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) => {
2722
- const actualIndex = start + offset;
2723
- const isSelected = actualIndex === selectedIndex;
2724
- const touched = fileCounts.get(turn.id) ?? 0;
2725
- return (_jsx(RewindRow, { turn: turn, turnNumber: actualIndex + 1, selected: isSelected, fileCount: touched, previewWidth: previewWidth }, turn.id));
2726
- })] })] }));
2727
- }
2728
- function RewindRow({ turn, turnNumber, selected, fileCount, previewWidth, }) {
2729
- const theme = useTheme();
2730
- const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2731
- const fileNote = fileCount > 0 ? ` · ${fileCount} file${fileCount === 1 ? "" : "s"}` : "";
2732
- 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 })] }));
2733
- }
2734
- function clampWindowStartForIndex(total, selectedIndex, maxVisible) {
2735
- if (total <= maxVisible)
2736
- return 0;
2737
- const half = Math.floor(maxVisible / 2);
2738
- let start = Math.max(0, selectedIndex - half);
2739
- if (start + maxVisible > total)
2740
- start = total - maxVisible;
2741
- return Math.max(0, start);
2742
- }
2743
- function StatsPanel({ panel, terminalColumns, terminalRows, onRangeChange, onCancel, }) {
2744
- const theme = useTheme();
2745
- const [scroll, setScroll] = useState(0);
2746
- const bodyWidth = Math.max(48, Math.min(92, terminalColumns - 6));
2747
- const lines = useMemo(() => formatStatsPanelBody(panel.bundle.ranges[panel.range], bodyWidth).split("\n"), [bodyWidth, panel.bundle, panel.range]);
2748
- const maxVisible = Math.max(5, Math.min(16, terminalRows - 10));
2749
- const maxScroll = Math.max(0, lines.length - maxVisible);
2750
- useEffect(() => {
2751
- setScroll(0);
2752
- }, [panel.range]);
2753
- useEffect(() => {
2754
- setScroll((current) => Math.min(current, maxScroll));
2755
- }, [maxScroll]);
2756
- useInput((input, key) => {
2757
- if (isKeyReleaseEvent(key))
2758
- return;
2759
- if (key.escape) {
2760
- onCancel();
2761
- return;
2762
- }
2763
- if (key.tab) {
2764
- onRangeChange(panel.range === "30d" ? "7d" : "30d");
2765
- return;
2766
- }
2767
- if (key.leftArrow || input === "h") {
2768
- onRangeChange("7d");
2769
- return;
2770
- }
2771
- if (key.rightArrow || input === "l") {
2772
- onRangeChange("30d");
2773
- return;
2774
- }
2775
- if (key.upArrow) {
2776
- setScroll((current) => Math.max(0, current - 1));
2777
- return;
2778
- }
2779
- if (key.downArrow) {
2780
- setScroll((current) => Math.min(maxScroll, current + 1));
2781
- return;
2782
- }
2783
- if (key.pageUp) {
2784
- setScroll((current) => Math.max(0, current - maxVisible));
2785
- return;
2786
- }
2787
- if (key.pageDown) {
2788
- setScroll((current) => Math.min(maxScroll, current + maxVisible));
2789
- }
2790
- });
2791
- const visible = lines.slice(scroll, scroll + maxVisible);
2792
- const generatedAt = panel.bundle.generatedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2793
- 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) => {
2794
- const key = `${scroll + index}-${line}`;
2795
- const heading = line === "Activity" || line === "Model usage" || line === "Summary";
2796
- return (_jsx(Text, { color: heading ? theme.accent : undefined, bold: heading, children: line || " " }, key));
2797
- }) }), maxScroll > 0 && (_jsxs(Text, { color: theme.muted, children: [scroll + 1, "-", Math.min(lines.length, scroll + maxVisible), " of ", lines.length] }))] }));
2798
- }
2799
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2800
- const GENERIC_PHRASES = [
2801
- "mapping the workspace",
2802
- "reading the room",
2803
- "following the threads",
2804
- "connecting the pieces",
2805
- "sorting the context",
2806
- "scanning the structure",
2807
- "shaping the next step",
2808
- "gathering signal",
2809
- "checking the edges",
2810
- "lining up the answer",
2811
- "tracing the flow",
2812
- "building the picture",
2813
- "walking the graph",
2814
- "collecting the clues",
2815
- "framing the problem",
2816
- "locating the source",
2817
- "resolving the shape",
2818
- "untangling the state",
2819
- "comparing the paths",
2820
- "narrowing the target",
2821
- "tracking the changes",
2822
- "reading the patterns",
2823
- "weighing the options",
2824
- "assembling the context",
2825
- "following the signal",
2826
- "checking the assumptions",
2827
- "aligning the details",
2828
- "testing the shape",
2829
- "pulling the thread",
2830
- "cleaning the edges",
2831
- "refining the draft",
2832
- "verifying the route",
2833
- "making sense of it",
2834
- "looking for leverage",
2835
- "stitching the answer",
2836
- "holding the thread",
2837
- "distilling the noise",
2838
- "finding the seam",
2839
- "reading between the lines",
2840
- "preparing the response",
2841
- ];
2842
- const TOOL_TARGET_PHRASES = {
2843
- read: "reading files",
2844
- write: "writing changes",
2845
- edit: "patching files",
2846
- grep: "searching the codebase",
2847
- glob: "scanning paths",
2848
- ls: "listing directories",
2849
- bash: "running command",
2850
- web_search: "searching the web",
2851
- web_fetch: "fetching a page",
2852
- task: "spawning subagent",
2853
- };
2854
- function formatTokensApprox(chars) {
2855
- const tokens = Math.max(0, Math.round(chars / 4));
2856
- if (tokens < 1000)
2857
- return `${tokens}`;
2858
- if (tokens < 10000)
2859
- return `${(tokens / 1000).toFixed(1)}k`;
2860
- return `${Math.round(tokens / 1000)}k`;
2861
- }
2862
- function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, pendingSteerCount = 0, queuedCount = 0, }) {
2863
- void nowTick;
2864
- const theme = useTheme();
2865
- const [frameIndex, setFrameIndex] = useState(0);
2866
- const [idlePhrase, setIdlePhrase] = useState(() => GENERIC_PHRASES[0]);
2867
- // Frame timer is independent of the agent state — keeps animation smooth.
2868
- useEffect(() => {
2869
- const t = setInterval(() => {
2870
- setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
2871
- }, 100);
2872
- return () => clearInterval(t);
2873
- }, []);
2874
- // Determine state: active tool > streaming text > streaming reasoning > idle
2875
- const activeTool = [...tools].reverse().find((t) => !t.result);
2876
- const state = activeTool
2877
- ? "tool"
2878
- : hasStreamingText
2879
- ? "text"
2880
- : hasStreamingReasoning
2881
- ? "reasoning"
2882
- : "idle";
2883
- // Rotate idle phrases on a slower cadence; only matters in the idle state.
2884
- useEffect(() => {
2885
- if (state !== "idle")
2886
- return;
2887
- const t = setInterval(() => {
2888
- setIdlePhrase((current) => {
2889
- const candidates = GENERIC_PHRASES.filter((item) => item !== current);
2890
- return candidates[Math.floor(Math.random() * candidates.length)] || current;
2891
- });
2892
- }, 1500);
2893
- return () => clearInterval(t);
2894
- }, [state]);
2895
- let phrase;
2896
- if (state === "tool" && activeTool) {
2897
- phrase =
2898
- TOOL_TARGET_PHRASES[activeTool.name] || `running ${activeTool.name}`;
2899
- }
2900
- else if (state === "text") {
2901
- phrase = "writing the response";
2902
- }
2903
- else if (state === "reasoning") {
2904
- phrase = "working through the request";
2905
- }
2906
- else {
2907
- phrase = idlePhrase;
2908
- }
2909
- const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
2910
- const hintParts = [];
2911
- if (tokenText)
2912
- hintParts.push(tokenText);
2913
- if (pendingSteerCount > 0)
2914
- hintParts.push(`${pendingSteerCount} pending steer${pendingSteerCount === 1 ? "" : "s"}`);
2915
- if (queuedCount > 0)
2916
- hintParts.push(`${queuedCount} queued`);
2917
- hintParts.push("enter steer", "tab queue", "esc stop");
2918
- 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(" · "), ")"] })] }));
2919
- }