@bubblebrain-ai/bubble 0.0.49 → 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 (38) 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/provider-fingerprint.d.ts +6 -0
  4. package/dist/agent/provider-fingerprint.js +89 -0
  5. package/dist/agent/resident-history.d.ts +7 -0
  6. package/dist/agent/resident-history.js +61 -0
  7. package/dist/agent/transcript-helpers.d.ts +20 -0
  8. package/dist/agent/transcript-helpers.js +62 -0
  9. package/dist/agent/update-queue.d.ts +13 -0
  10. package/dist/agent/update-queue.js +69 -0
  11. package/dist/agent.d.ts +32 -15
  12. package/dist/agent.js +79 -297
  13. package/dist/external-runtime/grok-runtime.js +1 -1
  14. package/dist/feishu/agent-host/run-driver.js +1 -1
  15. package/dist/lsp/service.js +2 -2
  16. package/dist/model-catalog.js +8 -6
  17. package/dist/permissions/settings.d.ts +0 -1
  18. package/dist/permissions/settings.js +0 -2
  19. package/dist/provider-registry.js +13 -1
  20. package/dist/provider-transform.js +7 -8
  21. package/dist/slash-commands/commands.js +13 -9
  22. package/dist/tools/grep.js +1 -1
  23. package/dist/tui-ink/app-helpers.d.ts +43 -0
  24. package/dist/tui-ink/app-helpers.js +150 -0
  25. package/dist/tui-ink/app.d.ts +4 -11
  26. package/dist/tui-ink/app.js +56 -645
  27. package/dist/tui-ink/command-palette.d.ts +32 -0
  28. package/dist/tui-ink/command-palette.js +158 -0
  29. package/dist/tui-ink/display-reconstruct.d.ts +3 -0
  30. package/dist/tui-ink/display-reconstruct.js +82 -0
  31. package/dist/tui-ink/rewind-picker.d.ts +8 -0
  32. package/dist/tui-ink/rewind-picker.js +102 -0
  33. package/dist/tui-ink/session-picker.js +1 -1
  34. package/dist/tui-ink/stats-panel.d.ts +11 -0
  35. package/dist/tui-ink/stats-panel.js +65 -0
  36. package/dist/tui-ink/waiting-indicator.d.ts +12 -0
  37. package/dist/tui-ink/waiting-indicator.js +129 -0
  38. 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";
@@ -49,234 +48,25 @@ import { tokenUsageTotal } from "../goal/usage.js";
49
48
  import { formatInternalContextBlock, isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
50
49
  import { MAX_ACTIVE_LOOPS, decideLoopFiring, formatInterval, formatLoopList, parseLoopCommand, } from "../loop/engine.js";
51
50
  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";
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";
54
60
  import { GrokRuntimeError } from "../external-runtime/grok-errors.js";
55
- 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";
56
62
  import { GROK_SUBSCRIPTION_PROVIDER_ID, isGrokSubscriptionProviderId, withGrokSubscriptionProvider, } from "../external-runtime/grok-provider.js";
57
63
  import { classifyExternalRuntimeBinding, stopExternalRuntimeForSessionSwitch, } from "../external-runtime/session-policy.js";
58
- import os from "node:os";
59
64
  import { rmSync } from "node:fs";
60
65
  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
66
  // Canonical implementation lives in the sanitizer module so non-TUI surfaces
93
67
  // (session pickers, desktop host) share it; re-exported here for existing users.
94
68
  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 }) {
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 }) {
280
70
  const [sessionManager, setSessionManager] = useState(initialSessionManager);
281
71
  const [externalRuntimeBinding, setExternalRuntimeBinding] = useState(() => initialSessionManager?.getMetadata().externalRuntime);
282
72
  const sessionManagerRef = useRef(sessionManager);
@@ -372,8 +162,30 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
372
162
  });
373
163
  return () => { cancelled = true; };
374
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.
375
177
  useEffect(() => {
376
- setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
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
+ };
377
189
  }, [agent]);
378
190
  const [currentUpdateNotice, setCurrentUpdateNotice] = useState(updateNotice);
379
191
  const [pendingPlan, setPendingPlan] = useState(null);
@@ -403,12 +215,19 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
403
215
  // MessageList so Ink discards its already-printed rows and re-prints the
404
216
  // rebuilt list onto a freshly-cleared screen instead of appending duplicates.
405
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();
406
225
  const reprintTranscript = useCallback(() => {
407
226
  if (process.stdout.isTTY) {
408
- process.stdout.write("\x1b[0m\x1b[2J\x1b[3J\x1b[H");
227
+ writeThroughInk("\x1b[0m\x1b[2J\x1b[3J\x1b[H");
409
228
  }
410
229
  setStaticGeneration((generation) => generation + 1);
411
- }, []);
230
+ }, [writeThroughInk]);
412
231
  // Steer/queue while the agent runs:
413
232
  // Enter steers the current run via the agent's input controller; Tab (or an
414
233
  // ineligible input) queues for the next turn. Both render placeholder user
@@ -1098,6 +917,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1098
917
  setThinkingLevel,
1099
918
  sessionManager: targetSessionManager,
1100
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);
1101
925
  // Binary thinking toggles and thinking-only models use internal level
1102
926
  // placeholders; do not present those placeholders as real effort grades.
1103
927
  const decodedModel = decodeModel(model);
@@ -1107,7 +931,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1107
931
  const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
1108
932
  ? (isThinkingToggle || isThinkingOnly ? " in thinking mode" : ` with ${nextThinkingLevel} effort`)
1109
933
  : "";
1110
- setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
1111
934
  addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`, "ui_notice");
1112
935
  closePicker();
1113
936
  return nextThinkingLevel;
@@ -1167,8 +990,10 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1167
990
  transitionToNative,
1168
991
  onExternalRuntimeChange: refreshExternalRuntimeBinding,
1169
992
  });
1170
- if (handled && result)
1171
- addMessage("assistant", result, slashResultNoticeKind(result));
993
+ if (handled) {
994
+ if (result)
995
+ addMessage("assistant", result, slashResultNoticeKind(result));
996
+ }
1172
997
  }, [
1173
998
  addMessage,
1174
999
  agent,
@@ -1758,7 +1583,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1758
1583
  goalRunTokens += tokenUsageTotal(event.usage);
1759
1584
  }
1760
1585
  assistantSystemFingerprint = event.systemFingerprint;
1761
- setContextUsage(formatContextUsageLabel(agent.getContextUsageSnapshot()));
1762
1586
  if (event.willContinue) {
1763
1587
  commitAssistantMessage();
1764
1588
  clearAssistantStream();
@@ -2562,416 +2386,3 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2562
2386
  contextUsage: externalSessionBound ? undefined : (contextUsage || undefined),
2563
2387
  }) }) }))] }) }));
2564
2388
  }
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
- }