@bubblebrain-ai/bubble 0.0.12 → 0.0.14

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 (180) hide show
  1. package/dist/agent/execution-governor.js +1 -1
  2. package/dist/agent/input-controller.d.ts +11 -0
  3. package/dist/agent/input-controller.js +30 -0
  4. package/dist/agent/tool-intent.js +1 -0
  5. package/dist/agent.d.ts +8 -4
  6. package/dist/agent.js +623 -312
  7. package/dist/approval/controller.d.ts +1 -0
  8. package/dist/approval/controller.js +20 -3
  9. package/dist/approval/tool-helper.js +2 -0
  10. package/dist/approval/types.d.ts +14 -1
  11. package/dist/context/compact.js +9 -3
  12. package/dist/context/projector.js +27 -12
  13. package/dist/debug-trace.d.ts +27 -0
  14. package/dist/debug-trace.js +385 -0
  15. package/dist/feishu/agent-host/approval-card.js +9 -0
  16. package/dist/feishu/serve.js +7 -1
  17. package/dist/main.js +86 -9
  18. package/dist/model-catalog.js +1 -0
  19. package/dist/orchestrator/default-hooks.js +19 -8
  20. package/dist/orchestrator/hooks.d.ts +1 -0
  21. package/dist/prompt/environment.js +2 -0
  22. package/dist/prompt/reminders.d.ts +5 -6
  23. package/dist/prompt/reminders.js +8 -9
  24. package/dist/prompt/runtime.js +2 -2
  25. package/dist/provider-openai-codex.d.ts +7 -0
  26. package/dist/provider-openai-codex.js +265 -124
  27. package/dist/provider-registry.d.ts +2 -0
  28. package/dist/provider-registry.js +58 -9
  29. package/dist/provider.d.ts +3 -0
  30. package/dist/provider.js +5 -1
  31. package/dist/session-log.js +13 -1
  32. package/dist/slash-commands/commands.js +39 -0
  33. package/dist/slash-commands/types.d.ts +12 -0
  34. package/dist/stats/usage.d.ts +52 -0
  35. package/dist/stats/usage.js +414 -0
  36. package/dist/tools/apply-patch.d.ts +9 -0
  37. package/dist/tools/apply-patch.js +330 -0
  38. package/dist/tools/bash.js +205 -44
  39. package/dist/tools/edit-apply.d.ts +5 -2
  40. package/dist/tools/edit-apply.js +221 -31
  41. package/dist/tools/edit.js +12 -3
  42. package/dist/tools/file-mutation-queue.d.ts +1 -0
  43. package/dist/tools/file-mutation-queue.js +12 -1
  44. package/dist/tools/index.d.ts +2 -0
  45. package/dist/tools/index.js +7 -1
  46. package/dist/tools/patch-apply.d.ts +41 -0
  47. package/dist/tools/patch-apply.js +312 -0
  48. package/dist/tools/server-manager.d.ts +36 -0
  49. package/dist/tools/server-manager.js +234 -0
  50. package/dist/tools/server.d.ts +6 -0
  51. package/dist/tools/server.js +245 -0
  52. package/dist/tools/write.d.ts +3 -6
  53. package/dist/tools/write.js +26 -46
  54. package/dist/tui/clipboard.d.ts +1 -0
  55. package/dist/tui/clipboard.js +53 -0
  56. package/dist/tui/detect-theme.d.ts +2 -0
  57. package/dist/tui/detect-theme.js +87 -0
  58. package/dist/tui/display-history.d.ts +63 -0
  59. package/dist/tui/display-history.js +306 -0
  60. package/dist/tui/edit-diff.d.ts +11 -0
  61. package/dist/tui/edit-diff.js +57 -0
  62. package/dist/tui/escape-confirmation.d.ts +15 -0
  63. package/dist/tui/escape-confirmation.js +30 -0
  64. package/dist/tui/file-mentions.d.ts +29 -0
  65. package/dist/tui/file-mentions.js +174 -0
  66. package/dist/tui/global-key-router.d.ts +3 -0
  67. package/dist/tui/global-key-router.js +87 -0
  68. package/dist/tui/image-paste.d.ts +95 -0
  69. package/dist/tui/image-paste.js +505 -0
  70. package/dist/tui/input-history.d.ts +16 -0
  71. package/dist/tui/input-history.js +79 -0
  72. package/dist/tui/markdown-inline.d.ts +22 -0
  73. package/dist/tui/markdown-inline.js +68 -0
  74. package/dist/tui/markdown-theme-rules.d.ts +23 -0
  75. package/dist/tui/markdown-theme-rules.js +164 -0
  76. package/dist/tui/markdown-theme.d.ts +5 -0
  77. package/dist/tui/markdown-theme.js +27 -0
  78. package/dist/tui/model-picker-data.d.ts +10 -0
  79. package/dist/tui/model-picker-data.js +32 -0
  80. package/dist/tui/opencode-spinner.d.ts +22 -0
  81. package/dist/tui/opencode-spinner.js +216 -0
  82. package/dist/tui/prompt-keybindings.d.ts +42 -0
  83. package/dist/tui/prompt-keybindings.js +35 -0
  84. package/dist/tui/recent-activity.d.ts +8 -0
  85. package/dist/tui/recent-activity.js +71 -0
  86. package/dist/tui/render-signature.d.ts +1 -0
  87. package/dist/tui/render-signature.js +7 -0
  88. package/dist/tui/run.d.ts +45 -0
  89. package/dist/tui/run.js +9359 -0
  90. package/dist/tui/session-display.d.ts +6 -0
  91. package/dist/tui/session-display.js +12 -0
  92. package/dist/tui/sidebar-mcp.d.ts +31 -0
  93. package/dist/tui/sidebar-mcp.js +62 -0
  94. package/dist/tui/sidebar-state.d.ts +12 -0
  95. package/dist/tui/sidebar-state.js +69 -0
  96. package/dist/tui/streaming-tool-args.d.ts +15 -0
  97. package/dist/tui/streaming-tool-args.js +30 -0
  98. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  99. package/dist/tui/tool-renderers/fallback.js +75 -0
  100. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  101. package/dist/tui/tool-renderers/registry.js +11 -0
  102. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  103. package/dist/tui/tool-renderers/subagent.js +135 -0
  104. package/dist/tui/tool-renderers/types.d.ts +36 -0
  105. package/dist/tui/tool-renderers/types.js +1 -0
  106. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  107. package/dist/tui/tool-renderers/write-preview.js +32 -0
  108. package/dist/tui/tool-renderers/write.d.ts +6 -0
  109. package/dist/tui/tool-renderers/write.js +88 -0
  110. package/dist/tui/trace-groups.d.ts +27 -0
  111. package/dist/tui/trace-groups.js +419 -0
  112. package/dist/tui/wordmark.d.ts +15 -0
  113. package/dist/tui/wordmark.js +179 -0
  114. package/dist/tui-ink/app.js +45 -9
  115. package/dist/tui-ink/approval/approval-dialog.js +7 -1
  116. package/dist/tui-ink/display-history.d.ts +1 -0
  117. package/dist/tui-ink/display-history.js +5 -4
  118. package/dist/tui-ink/message-list.js +23 -9
  119. package/dist/tui-ink/theme.d.ts +3 -9
  120. package/dist/tui-ink/theme.js +39 -45
  121. package/dist/tui-ink/trace-groups.js +1 -1
  122. package/dist/tui-ink/welcome.js +22 -78
  123. package/dist/tui-opentui/app.d.ts +54 -0
  124. package/dist/tui-opentui/app.js +1365 -0
  125. package/dist/tui-opentui/approval/approval-dialog.d.ts +15 -0
  126. package/dist/tui-opentui/approval/approval-dialog.js +145 -0
  127. package/dist/tui-opentui/approval/diff-view.d.ts +9 -0
  128. package/dist/tui-opentui/approval/diff-view.js +43 -0
  129. package/dist/tui-opentui/approval/select.d.ts +37 -0
  130. package/dist/tui-opentui/approval/select.js +91 -0
  131. package/dist/tui-opentui/detect-theme.d.ts +2 -0
  132. package/dist/tui-opentui/detect-theme.js +87 -0
  133. package/dist/tui-opentui/display-history.d.ts +56 -0
  134. package/dist/tui-opentui/display-history.js +130 -0
  135. package/dist/tui-opentui/edit-diff.d.ts +11 -0
  136. package/dist/tui-opentui/edit-diff.js +57 -0
  137. package/dist/tui-opentui/feedback-dialog.d.ts +21 -0
  138. package/dist/tui-opentui/feedback-dialog.js +164 -0
  139. package/dist/tui-opentui/feishu-setup-picker.d.ts +7 -0
  140. package/dist/tui-opentui/feishu-setup-picker.js +272 -0
  141. package/dist/tui-opentui/file-mentions.d.ts +29 -0
  142. package/dist/tui-opentui/file-mentions.js +174 -0
  143. package/dist/tui-opentui/footer.d.ts +26 -0
  144. package/dist/tui-opentui/footer.js +40 -0
  145. package/dist/tui-opentui/image-paste.d.ts +54 -0
  146. package/dist/tui-opentui/image-paste.js +288 -0
  147. package/dist/tui-opentui/input-box.d.ts +34 -0
  148. package/dist/tui-opentui/input-box.js +471 -0
  149. package/dist/tui-opentui/input-history.d.ts +16 -0
  150. package/dist/tui-opentui/input-history.js +79 -0
  151. package/dist/tui-opentui/markdown.d.ts +66 -0
  152. package/dist/tui-opentui/markdown.js +127 -0
  153. package/dist/tui-opentui/message-list.d.ts +31 -0
  154. package/dist/tui-opentui/message-list.js +128 -0
  155. package/dist/tui-opentui/model-picker.d.ts +63 -0
  156. package/dist/tui-opentui/model-picker.js +450 -0
  157. package/dist/tui-opentui/plan-confirm.d.ts +9 -0
  158. package/dist/tui-opentui/plan-confirm.js +124 -0
  159. package/dist/tui-opentui/question-dialog.d.ts +10 -0
  160. package/dist/tui-opentui/question-dialog.js +110 -0
  161. package/dist/tui-opentui/recent-activity.d.ts +8 -0
  162. package/dist/tui-opentui/recent-activity.js +71 -0
  163. package/dist/tui-opentui/run-session-picker.d.ts +10 -0
  164. package/dist/tui-opentui/run-session-picker.js +28 -0
  165. package/dist/tui-opentui/run.d.ts +38 -0
  166. package/dist/tui-opentui/run.js +48 -0
  167. package/dist/tui-opentui/session-picker.d.ts +12 -0
  168. package/dist/tui-opentui/session-picker.js +120 -0
  169. package/dist/tui-opentui/theme.d.ts +89 -0
  170. package/dist/tui-opentui/theme.js +157 -0
  171. package/dist/tui-opentui/todos.d.ts +9 -0
  172. package/dist/tui-opentui/todos.js +45 -0
  173. package/dist/tui-opentui/trace-groups.d.ts +27 -0
  174. package/dist/tui-opentui/trace-groups.js +419 -0
  175. package/dist/tui-opentui/use-terminal-size.d.ts +4 -0
  176. package/dist/tui-opentui/use-terminal-size.js +5 -0
  177. package/dist/tui-opentui/welcome.d.ts +25 -0
  178. package/dist/tui-opentui/welcome.js +77 -0
  179. package/dist/types.d.ts +36 -2
  180. package/package.json +5 -1
@@ -0,0 +1,1365 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
+ /** @jsxImportSource @opentui/react */
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useKeyboard, useRenderer } from "@opentui/react";
5
+ import { AgentAbortError } from "../agent.js";
6
+ import { registry as slashRegistry } from "../slash-commands/index.js";
7
+ import { UserConfig, maskKey } from "../config.js";
8
+ import { createPastedContentMarker, InputBox, shouldCollapsePastedContent, } from "./input-box.js";
9
+ import { MessageList } from "./message-list.js";
10
+ import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
11
+ import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
12
+ import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
13
+ import { FeishuSetupPicker } from "./feishu-setup-picker.js";
14
+ import { BUILTIN_PROVIDERS, ProviderRegistry, displayModel, isUserVisibleProvider } from "../provider-registry.js";
15
+ import { buildSystemPrompt } from "../system-prompt.js";
16
+ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
17
+ import { FooterBar, buildFooterData } from "./footer.js";
18
+ import { SkillRegistry } from "../skills/registry.js";
19
+ import { parseSkillInvocation } from "../skills/invocation.js";
20
+ import { useTerminalSize } from "./use-terminal-size.js";
21
+ import { HomeSurface, WelcomeBanner, shouldShowWelcomeBanner } from "./welcome.js";
22
+ import { expandAtMentions } from "./file-mentions.js";
23
+ import { TodosPanel } from "./todos.js";
24
+ import { PlanConfirm } from "./plan-confirm.js";
25
+ import { ApprovalDialog } from "./approval/approval-dialog.js";
26
+ import { getNextPermissionMode } from "../permission/mode.js";
27
+ import { QuestionDialog } from "./question-dialog.js";
28
+ import { FeedbackDialog } from "./feedback-dialog.js";
29
+ import { collectFeedback } from "../feedback/collect.js";
30
+ // OpenTUI handles mouse selection natively via useSelectionHandler; no need
31
+ // to filter escape-sequence noise out of stdin like we did in the Ink path.
32
+ import os from "node:os";
33
+ import { existsSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ function buildTips(agent, registry) {
36
+ const tips = [];
37
+ const hasProvider = registry.getEnabled().length > 0;
38
+ if (!hasProvider) {
39
+ tips.push("Run /login or /provider --add to configure a model");
40
+ }
41
+ else if (agent.model) {
42
+ tips.push(`Ready with ${displayModel(agent.model)}`);
43
+ }
44
+ else {
45
+ tips.push("Run /model to pick a model");
46
+ }
47
+ tips.push("Type @ to reference a file");
48
+ tips.push("Type / for commands and skills");
49
+ return tips;
50
+ }
51
+ function friendlyCwd(cwd) {
52
+ const home = os.homedir();
53
+ if (cwd === home)
54
+ return "~";
55
+ if (cwd.startsWith(home + "/"))
56
+ return "~" + cwd.slice(home.length);
57
+ return cwd;
58
+ }
59
+ function hasVisibleDisplayMessage(message) {
60
+ if (message.syntheticKind === "ui_summary")
61
+ return false;
62
+ if (message.syntheticKind === "ui_compact_summary")
63
+ return true;
64
+ if (message.role === "user" || message.role === "error")
65
+ return message.content.trim().length > 0;
66
+ return !!(message.content.trim() ||
67
+ message.reasoning?.trim() ||
68
+ (message.toolCalls?.length ?? 0) > 0 ||
69
+ (message.parts?.length ?? 0) > 0);
70
+ }
71
+ function reconstructDisplayMessages(agentMessages) {
72
+ const result = [];
73
+ for (const m of agentMessages) {
74
+ if (m.role === "system" || m.role === "tool")
75
+ continue;
76
+ if (m.role === "user") {
77
+ if (m.isMeta)
78
+ continue; // <system-reminder> injections are not user-visible
79
+ result.push({
80
+ key: nextDisplayMessageKey("user"),
81
+ role: "user",
82
+ content: typeof m.content === "string"
83
+ ? (shouldCollapsePastedContent(m.content) ? createPastedContentMarker(m.content) : m.content)
84
+ : "(multimedia)",
85
+ });
86
+ }
87
+ else if (m.role === "assistant") {
88
+ const toolCalls = [];
89
+ if (m.toolCalls) {
90
+ for (const tc of m.toolCalls) {
91
+ let args = {};
92
+ try {
93
+ args = JSON.parse(tc.arguments || "{}");
94
+ }
95
+ catch {
96
+ args = {};
97
+ }
98
+ const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
99
+ toolCalls.push({
100
+ id: tc.id,
101
+ name: tc.name,
102
+ args,
103
+ result: toolResult ? toolResult.content : undefined,
104
+ isError: toolResult ? toolResult.content?.startsWith?.("Error:") : false,
105
+ metadata: toolResult ? toolResult.metadata : undefined,
106
+ });
107
+ }
108
+ }
109
+ result.push({
110
+ key: nextDisplayMessageKey("asst"),
111
+ role: "assistant",
112
+ content: m.content,
113
+ reasoning: m.reasoning || undefined,
114
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
115
+ });
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+ /**
121
+ * Streaming tool arguments arrive as an incomplete JSON buffer. We can't
122
+ * JSON.parse() until the closing brace lands, but the user wants to see the
123
+ * short identifying fields (path, command, …) as soon as the model emits
124
+ * them so the tool row header reflects what's happening.
125
+ *
126
+ * Intentionally limited to short, single-line fields. Long fields like
127
+ * `content` are *not* surfaced live: rendering thousands of partial lines
128
+ * per delta floods the terminal and the partial value can break around
129
+ * unescaped sequences. The final value lands when the tool actually
130
+ * executes and tool_start delivers canonical args.
131
+ */
132
+ function parsePartialArgs(buffer, previous) {
133
+ // If the buffer is now valid JSON, prefer the real parse.
134
+ try {
135
+ const parsed = JSON.parse(buffer);
136
+ if (parsed && typeof parsed === "object")
137
+ return parsed;
138
+ }
139
+ catch {
140
+ // fall through to partial extraction below
141
+ }
142
+ const result = { ...previous };
143
+ const FIELDS = ["path", "command", "pattern", "url", "query"];
144
+ for (const field of FIELDS) {
145
+ // Match a complete-looking quoted string. Requires a closing quote so we
146
+ // don't surface half-typed paths that may still change as bytes arrive.
147
+ const match = buffer.match(new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`));
148
+ if (match) {
149
+ const raw = match[1] ?? "";
150
+ result[field] = raw
151
+ .replace(/\\n/g, "\n")
152
+ .replace(/\\t/g, "\t")
153
+ .replace(/\\"/g, '"')
154
+ .replace(/\\\\/g, "\\");
155
+ }
156
+ }
157
+ return result;
158
+ }
159
+ function mergeToolMetadata(current, incoming) {
160
+ if (!incoming)
161
+ return current;
162
+ if (current?.kind !== "subagent" || incoming.kind !== "subagent") {
163
+ return incoming;
164
+ }
165
+ const currentSubagents = Array.isArray(current.subagents) ? current.subagents : [];
166
+ const incomingSubagents = Array.isArray(incoming.subagents) ? incoming.subagents : [];
167
+ const byId = new Map();
168
+ for (const item of currentSubagents) {
169
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
170
+ ? String(item.subAgentId)
171
+ : "";
172
+ byId.set(subAgentId || `current:${byId.size}`, item);
173
+ }
174
+ for (const item of incomingSubagents) {
175
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
176
+ ? String(item.subAgentId)
177
+ : "";
178
+ byId.set(subAgentId || `incoming:${byId.size}`, item);
179
+ }
180
+ return {
181
+ ...current,
182
+ ...incoming,
183
+ subagents: [...byId.values()],
184
+ };
185
+ }
186
+ /**
187
+ * Coerce a freshly-constructed DisplayMessage into one that carries a stable
188
+ * `key`. Centralizes the safety net so callers don't have to remember to call
189
+ * nextDisplayMessageKey on every push.
190
+ */
191
+ function withMessageKey(message) {
192
+ if (message.key)
193
+ return message;
194
+ const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
195
+ return { ...message, key: nextDisplayMessageKey(prefix) };
196
+ }
197
+ // Keep the live (non-Static) region small so non-GPU terminals (xterm.js DOM
198
+ // renderer, ssh into a basic terminal, tmux without GPU) don't flicker when
199
+ // Ink re-reconciles the streaming block on every token. Flushing earlier and
200
+ // in smaller chunks shifts most of the answer into terminal scrollback, where
201
+ // it's a one-time write that doesn't get re-rendered.
202
+ const STREAMING_STATIC_FLUSH_MIN_CHARS = 600;
203
+ const STREAMING_STATIC_FLUSH_TARGET_CHARS = 400;
204
+ const STREAMING_STATIC_FLUSH_MIN_TAIL = 120;
205
+ /**
206
+ * True iff `prefix` ends inside an open ```/~~~ fenced code block. Splitting
207
+ * the streaming buffer at such a point would let the flushed half render
208
+ * without its closing fence — `MarkdownContent` would then treat the body as
209
+ * plain prose and the trailing half would render as an isolated code block
210
+ * with no opener. Fence delimiters of different families don't close each
211
+ * other (a `~~~` inside a ``` block is just text). We use a permissive
212
+ * "line starts with three or more of the same char" rule, ignoring the info
213
+ * string — that's enough to spot when we're mid-block.
214
+ */
215
+ function endsInsideUnclosedCodeFence(prefix) {
216
+ let openMarker = null;
217
+ for (const rawLine of prefix.split("\n")) {
218
+ const line = rawLine.replace(/^ {0,3}/, "");
219
+ if (openMarker === null) {
220
+ if (line.startsWith("```"))
221
+ openMarker = "`";
222
+ else if (line.startsWith("~~~"))
223
+ openMarker = "~";
224
+ }
225
+ else if (line.startsWith(openMarker.repeat(3))) {
226
+ openMarker = null;
227
+ }
228
+ }
229
+ return openMarker !== null;
230
+ }
231
+ function findStreamingStaticFlushIndex(content) {
232
+ if (content.length < STREAMING_STATIC_FLUSH_MIN_CHARS)
233
+ return -1;
234
+ const upper = Math.min(STREAMING_STATIC_FLUSH_TARGET_CHARS, content.length - STREAMING_STATIC_FLUSH_MIN_TAIL);
235
+ if (upper <= 0)
236
+ return -1;
237
+ const search = content.slice(0, upper);
238
+ const paragraphBreak = search.lastIndexOf("\n\n");
239
+ if (paragraphBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
240
+ const splitIndex = paragraphBreak + 2;
241
+ if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
242
+ return splitIndex;
243
+ }
244
+ }
245
+ const lineBreak = search.lastIndexOf("\n");
246
+ if (lineBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
247
+ const splitIndex = lineBreak + 1;
248
+ if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
249
+ return splitIndex;
250
+ }
251
+ }
252
+ // Inside an open code fence: hold off flushing until the closing fence
253
+ // arrives. The live region grows a bit, but Markdown rendering stays correct.
254
+ return -1;
255
+ }
256
+ function cloneDisplayPart(part) {
257
+ if (part.type === "text") {
258
+ return { type: "text", content: part.content };
259
+ }
260
+ return {
261
+ type: "tools",
262
+ toolCalls: part.toolCalls.map((toolCall) => ({
263
+ ...toolCall,
264
+ args: { ...toolCall.args },
265
+ })),
266
+ };
267
+ }
268
+ function splitDisplayPartsAtTextOffset(parts, offset) {
269
+ const flushedParts = [];
270
+ const remainingParts = [];
271
+ let remainingOffset = Math.max(0, offset);
272
+ let reachedTail = false;
273
+ for (const part of parts) {
274
+ if (part.type === "text") {
275
+ if (!reachedTail && remainingOffset >= part.content.length) {
276
+ if (part.content)
277
+ flushedParts.push(cloneDisplayPart(part));
278
+ remainingOffset -= part.content.length;
279
+ continue;
280
+ }
281
+ if (!reachedTail && remainingOffset > 0) {
282
+ const head = part.content.slice(0, remainingOffset);
283
+ const tail = part.content.slice(remainingOffset);
284
+ if (head)
285
+ flushedParts.push({ type: "text", content: head });
286
+ if (tail)
287
+ remainingParts.push({ type: "text", content: tail });
288
+ remainingOffset = 0;
289
+ reachedTail = true;
290
+ continue;
291
+ }
292
+ remainingParts.push(cloneDisplayPart(part));
293
+ reachedTail = true;
294
+ continue;
295
+ }
296
+ if (!reachedTail && remainingOffset > 0) {
297
+ flushedParts.push(cloneDisplayPart(part));
298
+ }
299
+ else {
300
+ remainingParts.push(cloneDisplayPart(part));
301
+ reachedTail = true;
302
+ }
303
+ }
304
+ return { flushedParts, remainingParts };
305
+ }
306
+ export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }) {
307
+ const [themeMode, setThemeMode] = useState(initialThemeMode ?? "auto");
308
+ // `detectedTheme` is captured once at startup in main.ts. We keep it in state
309
+ // so future re-detection (e.g. if a user runs `/theme auto` after switching
310
+ // their terminal) is possible without re-mounting the app. For now it never
311
+ // changes after first render.
312
+ const [autoResolved] = useState(detectedTheme ?? "dark");
313
+ const palette = useMemo(() => {
314
+ const resolved = themeMode === "auto" ? autoResolved : themeMode;
315
+ return paletteFor(resolved, themeOverrides);
316
+ }, [themeMode, autoResolved, themeOverrides]);
317
+ const applyThemeMode = useCallback((mode) => {
318
+ setThemeMode(mode);
319
+ onThemeModeChange?.(mode);
320
+ }, [onThemeModeChange]);
321
+ const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
322
+ const renderer = useRenderer();
323
+ const exit = useCallback(() => {
324
+ try {
325
+ renderer.destroy?.();
326
+ }
327
+ catch {
328
+ // ignore — already torn down
329
+ }
330
+ }, [renderer]);
331
+ const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
332
+ const [clearEpoch, setClearEpoch] = useState(0);
333
+ const [isRunning, setIsRunning] = useState(false);
334
+ const [streamingContent, setStreamingContent] = useState("");
335
+ const [streamingReasoning, setStreamingReasoning] = useState("");
336
+ const [streamingTools, setStreamingTools] = useState([]);
337
+ const [streamingParts, setStreamingParts] = useState([]);
338
+ const [usageTotals, setUsageTotals] = useState({ prompt: 0, completion: 0 });
339
+ const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
340
+ const [permissionMode, setPermissionMode] = useState(agent.mode);
341
+ const [todos, setTodos] = useState(() => agent.getTodos());
342
+ const [pendingPlan, setPendingPlan] = useState(null);
343
+ const [pendingApproval, setPendingApproval] = useState(null);
344
+ const [pendingQuestion, setPendingQuestion] = useState(null);
345
+ const [pendingFeedback, setPendingFeedback] = useState(null);
346
+ const [pickerMode, setPickerMode] = useState(null);
347
+ const [cursorResetEpoch, setCursorResetEpoch] = useState(0);
348
+ const [composerDraft, setComposerDraft] = useState(null);
349
+ const [keyProviderId, setKeyProviderId] = useState(null);
350
+ const [verboseTrace, setVerboseTrace] = useState(false);
351
+ const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
352
+ const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
353
+ const showWelcome = shouldShowWelcomeBanner({
354
+ messages,
355
+ startedWithVisibleHistory: startedWithVisibleHistoryRef.current,
356
+ });
357
+ const activeAbortRef = useRef(null);
358
+ const exitRequestedRef = useRef(false);
359
+ const sessionStartRef = useRef(Date.now());
360
+ const previousTerminalColumnsRef = useRef(null);
361
+ useEffect(() => {
362
+ if (previousTerminalColumnsRef.current === null) {
363
+ previousTerminalColumnsRef.current = terminalColumns;
364
+ return;
365
+ }
366
+ if (previousTerminalColumnsRef.current === terminalColumns)
367
+ return;
368
+ previousTerminalColumnsRef.current = terminalColumns;
369
+ // This follows Gemini CLI's normal terminal-buffer strategy: after a
370
+ // resize, the previous live Ink frame may have wrapped at the old width,
371
+ // so cursor-up based repaint can leave stale progress frames behind.
372
+ // Debounce resize storms, then clear and replay Static at the settled width.
373
+ const timer = setTimeout(() => {
374
+ if (exitRequestedRef.current)
375
+ return;
376
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
377
+ setClearEpoch((epoch) => epoch + 1);
378
+ }, 300);
379
+ return () => clearTimeout(timer);
380
+ }, [terminalColumns]);
381
+ // Set true the moment /quit is invoked so we can hide dynamic UI (composer,
382
+ // waiting indicator, footer) before Ink snapshots its final frame into the
383
+ // shell scrollback. Without this, the last visible "> " input row stays
384
+ // glued to the bottom of the terminal after exit.
385
+ const [isExiting, setIsExiting] = useState(false);
386
+ // 1Hz tick keeps the composer activity indicator animated while the agent is
387
+ // running without churning renders at idle.
388
+ const [nowTick, setNowTick] = useState(() => Date.now());
389
+ // Timestamp of when the current agent run started. Used only for the final
390
+ // per-task duration summary.
391
+ const runStartRef = useRef(null);
392
+ // Mark the moment the run started; flips back to null in the finally block.
393
+ useEffect(() => {
394
+ if (!isRunning)
395
+ return;
396
+ setNowTick(Date.now());
397
+ const t = setInterval(() => setNowTick(Date.now()), 1000);
398
+ return () => clearInterval(t);
399
+ }, [isRunning]);
400
+ const userConfig = new UserConfig();
401
+ const safeRegistry = registry ?? new ProviderRegistry(userConfig);
402
+ const safeSkillRegistry = skillRegistry ?? new SkillRegistry({
403
+ cwd: args.cwd,
404
+ skillPaths: userConfig.getSkillPaths(),
405
+ });
406
+ const requestExit = useCallback(() => {
407
+ if (exitRequestedRef.current)
408
+ return;
409
+ exitRequestedRef.current = true;
410
+ // Drop the composer / waiting indicator / footer from the React tree
411
+ // *before* we tell Ink to exit, so Ink's final log-update snapshot
412
+ // doesn't leave an empty "> " row behind in the shell scrollback.
413
+ setIsExiting(true);
414
+ // Cancel any in-flight agent run first so its tools / network calls
415
+ // don't keep emitting text after Ink unmounts and corrupt the
416
+ // restored shell prompt.
417
+ if (activeAbortRef.current) {
418
+ try {
419
+ activeAbortRef.current.abort(new AgentAbortError("Exiting Bubble."));
420
+ }
421
+ catch {
422
+ // ignore — abort is best effort during shutdown
423
+ }
424
+ activeAbortRef.current = null;
425
+ }
426
+ void (async () => {
427
+ // Yield once so React can commit the `isExiting=true` render
428
+ // (which strips the composer/footer) before we hand control to
429
+ // Ink's teardown. Without this, on the no-flushMemory path the
430
+ // exit() below races the next React commit and Ink snapshots the
431
+ // pre-exit frame with the composer still visible.
432
+ await new Promise((resolve) => setImmediate(resolve));
433
+ let flushError = null;
434
+ if (flushMemory) {
435
+ // Bound the flush so a stuck LLM/network call cannot trap the TUI.
436
+ let timer;
437
+ try {
438
+ await Promise.race([
439
+ flushMemory(),
440
+ new Promise((_, reject) => {
441
+ timer = setTimeout(() => reject(new Error("flushMemory timed out after 3s")), 3000);
442
+ }),
443
+ ]);
444
+ }
445
+ catch (err) {
446
+ flushError = err;
447
+ }
448
+ finally {
449
+ if (timer)
450
+ clearTimeout(timer);
451
+ }
452
+ }
453
+ // Hand off to Ink. Ink's render instance owns TTY teardown (raw mode,
454
+ // cursor, alt-screen); doing it ourselves here races with that and
455
+ // leaves the terminal in odd states. run.tsx awaits waitUntilExit()
456
+ // and then main.ts handles the rest.
457
+ exit();
458
+ // Surface flush failures *after* Ink has restored the screen so the
459
+ // warning lands on the real shell instead of being clobbered.
460
+ if (flushError) {
461
+ const message = flushError instanceof Error ? flushError.message : String(flushError);
462
+ process.nextTick(() => {
463
+ process.stderr.write(`warning: failed to flush memory on exit: ${message}\n`);
464
+ });
465
+ }
466
+ onExit?.({ wallMs: Date.now() - sessionStartRef.current });
467
+ })();
468
+ }, [exit, flushMemory, onExit]);
469
+ useEffect(() => {
470
+ if (!planHandlerRef)
471
+ return;
472
+ planHandlerRef.current = (plan) => new Promise((resolve) => {
473
+ setPendingPlan({ plan, resolve });
474
+ });
475
+ return () => {
476
+ if (planHandlerRef.current) {
477
+ planHandlerRef.current = undefined;
478
+ }
479
+ };
480
+ }, [planHandlerRef]);
481
+ useEffect(() => {
482
+ if (!approvalHandlerRef)
483
+ return;
484
+ approvalHandlerRef.current = (request) => new Promise((resolve) => {
485
+ setPendingApproval({ request, resolve });
486
+ });
487
+ return () => {
488
+ if (approvalHandlerRef.current) {
489
+ approvalHandlerRef.current = undefined;
490
+ }
491
+ };
492
+ }, [approvalHandlerRef]);
493
+ useEffect(() => {
494
+ if (!questionController)
495
+ return;
496
+ const syncFirstPending = () => {
497
+ setPendingQuestion((current) => current ?? questionController.list()[0] ?? null);
498
+ };
499
+ const unsubscribe = questionController.subscribe((event) => {
500
+ if (event.type === "asked") {
501
+ setPendingQuestion(event.request);
502
+ return;
503
+ }
504
+ setPendingQuestion((current) => current?.id === event.request.id ? null : current);
505
+ setTimeout(syncFirstPending, 0);
506
+ });
507
+ syncFirstPending();
508
+ return unsubscribe;
509
+ }, [questionController]);
510
+ const rebuildSystemPrompt = useCallback((overrides) => {
511
+ const modelParts = agent.model.includes(":")
512
+ ? agent.model.split(":")
513
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
514
+ const providerId = modelParts[0];
515
+ agent.setSystemPrompt(buildSystemPrompt({
516
+ agentName: "Bubble",
517
+ configuredProvider: providerId,
518
+ configuredModel: displayModel(agent.model),
519
+ configuredModelId: agent.model,
520
+ thinkingLevel: overrides?.thinkingLevel ?? agent.thinking,
521
+ mode: overrides?.mode ?? agent.mode,
522
+ workingDir: args.cwd,
523
+ }));
524
+ }, [agent, args.cwd, safeRegistry, safeSkillRegistry]);
525
+ useKeyboard((key) => {
526
+ if (key.eventType === "release")
527
+ return;
528
+ if (key.ctrl && key.name === "c") {
529
+ requestExit();
530
+ return;
531
+ }
532
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback)
533
+ return;
534
+ if (key.ctrl && key.name === "o" && !pickerMode) {
535
+ setVerboseTrace((v) => !v);
536
+ return;
537
+ }
538
+ // Ctrl+R: cycle thinking level (formerly Shift+Tab)
539
+ if (key.ctrl && key.name === "r" && !pickerMode) {
540
+ const modelParts = agent.model.includes(":")
541
+ ? agent.model.split(":")
542
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
543
+ const providerId = modelParts[0];
544
+ const modelId = modelParts.slice(1).join(":");
545
+ const availableLevels = getAvailableThinkingLevels(providerId, modelId);
546
+ const currentLevel = normalizeThinkingLevel(agent.thinking, availableLevels);
547
+ const currentIndex = availableLevels.indexOf(currentLevel);
548
+ const nextLevel = availableLevels[(currentIndex + 1) % availableLevels.length];
549
+ agent.thinking = nextLevel;
550
+ rebuildSystemPrompt({ thinkingLevel: nextLevel });
551
+ userConfig.setDefaultThinkingLevel(nextLevel);
552
+ setThinkingLevel(nextLevel);
553
+ sessionManager?.updateMetadata({ model: agent.model, thinkingLevel: nextLevel, reasoningEffort: nextLevel });
554
+ sessionManager?.appendMarker("thinking_level_switch", nextLevel);
555
+ return;
556
+ }
557
+ // Shift+Tab: cycle through permission modes (default → acceptEdits → plan
558
+ // → [bypassPermissions if enabled] → default). Agent.setMode injects a
559
+ // <system-reminder>, so we do not rebuild the cache-friendly system prompt here.
560
+ if (key.name === "tab" && key.shift && !pickerMode) {
561
+ const nextMode = getNextPermissionMode(agent.mode);
562
+ agent.setMode(nextMode);
563
+ setPermissionMode(nextMode);
564
+ sessionManager?.appendMarker("mode_switch", nextMode);
565
+ return;
566
+ }
567
+ if (key.name === "escape" && !pickerMode) {
568
+ if (isRunning && activeAbortRef.current) {
569
+ activeAbortRef.current.abort(new AgentAbortError("Agent run cancelled by user."));
570
+ return;
571
+ }
572
+ }
573
+ });
574
+ const updateDisplayMessages = useCallback((updater) => {
575
+ setMessages((prev) => compactDisplayMessages(updater(prev).map(withMessageKey)));
576
+ }, []);
577
+ const addMessage = useCallback((role, content) => {
578
+ updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
579
+ }, [updateDisplayMessages]);
580
+ const clearMessages = useCallback(() => {
581
+ // Static history is already written to terminal scrollback, so clearing
582
+ // React state alone would leave old rows visible.
583
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
584
+ setMessages([]);
585
+ setClearEpoch((epoch) => epoch + 1);
586
+ }, []);
587
+ const openPicker = useCallback((mode, providerId) => {
588
+ if (mode === "key") {
589
+ setKeyProviderId(providerId ?? null);
590
+ }
591
+ setPickerMode(mode);
592
+ }, []);
593
+ const closePicker = useCallback(() => {
594
+ setPickerMode(null);
595
+ setCursorResetEpoch((epoch) => epoch + 1);
596
+ }, []);
597
+ const fillComposer = useCallback((text) => {
598
+ setComposerDraft((current) => ({
599
+ text,
600
+ epoch: (current?.epoch ?? 0) + 1,
601
+ }));
602
+ }, []);
603
+ const clearComposerDraft = useCallback(() => {
604
+ setComposerDraft(null);
605
+ }, []);
606
+ const openFeedback = useCallback((initialDescription) => {
607
+ const base = collectFeedback(agent, { description: "" });
608
+ const { description: _drop, ...rest } = base;
609
+ setPendingFeedback({ base: rest, initialDescription });
610
+ }, [agent]);
611
+ const handleModelSelect = useCallback((model) => {
612
+ const run = async () => {
613
+ agent.model = model;
614
+ const decoded = model.includes(":")
615
+ ? model.split(":")
616
+ : [agent.providerId || safeRegistry.getDefault()?.id || "openai", model];
617
+ const providerId = decoded[0];
618
+ await safeRegistry.prepareProvider(providerId);
619
+ const provider = safeRegistry.getConfigured().find((item) => item.id === providerId);
620
+ if (!provider?.apiKey || !createProvider) {
621
+ addMessage("error", `Provider ${providerId} is not configured or has no active credentials.`);
622
+ closePicker();
623
+ return;
624
+ }
625
+ const modelId = model.includes(":") ? model.split(":").slice(1).join(":") : model;
626
+ agent.thinking = normalizeThinkingLevel(agent.thinking || getDefaultThinkingLevel(providerId, modelId), getAvailableThinkingLevels(providerId, modelId));
627
+ agent.setProvider(createProvider(providerId, provider.apiKey, provider.baseURL));
628
+ agent.providerId = providerId;
629
+ agent.setSystemPrompt(buildSystemPrompt({
630
+ agentName: "Bubble",
631
+ configuredProvider: providerId,
632
+ configuredModel: displayModel(model),
633
+ configuredModelId: model,
634
+ thinkingLevel: agent.thinking,
635
+ workingDir: args.cwd,
636
+ }));
637
+ userConfig.pushRecentModel(model);
638
+ setThinkingLevel(agent.thinking);
639
+ sessionManager?.updateMetadata({ model, thinkingLevel: agent.thinking, reasoningEffort: agent.thinking });
640
+ sessionManager?.appendMarker("model_switch", model);
641
+ addMessage("assistant", `Model switched to ${displayModel(model)}.`);
642
+ closePicker();
643
+ };
644
+ void run();
645
+ }, [agent, addMessage, closePicker, sessionManager, userConfig, safeRegistry, createProvider]);
646
+ const handleProviderSelect = useCallback(async (providerId) => {
647
+ await safeRegistry.prepareProvider(providerId);
648
+ const configured = safeRegistry.getConfigured();
649
+ const p = configured.find((x) => x.id === providerId);
650
+ const builtin = BUILTIN_PROVIDERS.find((x) => x.id === providerId);
651
+ if (!p && !builtin) {
652
+ addMessage("error", `Provider ${providerId} not found.`);
653
+ closePicker();
654
+ return;
655
+ }
656
+ if (!p?.apiKey) {
657
+ if (!p && builtin) {
658
+ safeRegistry.addProvider(providerId, "");
659
+ }
660
+ safeRegistry.setDefault(providerId);
661
+ setKeyProviderId(providerId);
662
+ setPickerMode("key");
663
+ return;
664
+ }
665
+ safeRegistry.setDefault(providerId);
666
+ agent.setProvider(createProvider(providerId, p.apiKey, p.baseURL));
667
+ agent.providerId = providerId;
668
+ addMessage("assistant", `Switched to provider ${p.name}. Use /model to pick a model.`);
669
+ closePicker();
670
+ }, [addMessage, agent, closePicker, createProvider, safeRegistry]);
671
+ const handleProviderAddSelect = useCallback((providerId) => {
672
+ const ok = safeRegistry.addProvider(providerId, "");
673
+ if (!ok) {
674
+ addMessage("error", `Provider ${providerId} could not be added.`);
675
+ closePicker();
676
+ return;
677
+ }
678
+ safeRegistry.setDefault(providerId);
679
+ setKeyProviderId(providerId);
680
+ setPickerMode("key");
681
+ }, [addMessage, closePicker, safeRegistry]);
682
+ const handleLoginProviderSelect = useCallback(async (providerId) => {
683
+ closePicker();
684
+ const command = `/login ${providerId}`;
685
+ const { handled, result } = await slashRegistry.execute(command, {
686
+ agent,
687
+ addMessage,
688
+ clearMessages,
689
+ cwd: args.cwd,
690
+ exit: () => { requestExit(); },
691
+ sessionManager,
692
+ createProvider: createProvider ?? (() => {
693
+ throw new Error("Provider creation not available");
694
+ }),
695
+ openPicker,
696
+ openFeedback,
697
+ registry: safeRegistry,
698
+ skillRegistry: safeSkillRegistry,
699
+ bashAllowlist,
700
+ settingsManager,
701
+ lspService,
702
+ mcpManager,
703
+ flushMemory,
704
+ runMemoryCompaction,
705
+ runMemorySummary,
706
+ runMemoryRefresh,
707
+ getThemeMode: () => themeMode,
708
+ getResolvedTheme: () => themeResolved,
709
+ setThemeMode: applyThemeMode,
710
+ });
711
+ if (handled && result) {
712
+ addMessage("assistant", result);
713
+ }
714
+ }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, openPicker, safeRegistry, sessionManager]);
715
+ const handleLogoutProviderSelect = useCallback(async (providerId) => {
716
+ closePicker();
717
+ const command = `/logout ${providerId}`;
718
+ const { handled, result } = await slashRegistry.execute(command, {
719
+ agent,
720
+ addMessage,
721
+ clearMessages,
722
+ cwd: args.cwd,
723
+ exit: () => { requestExit(); },
724
+ sessionManager,
725
+ createProvider: createProvider ?? (() => {
726
+ throw new Error("Provider creation not available");
727
+ }),
728
+ openPicker,
729
+ openFeedback,
730
+ registry: safeRegistry,
731
+ skillRegistry: safeSkillRegistry,
732
+ bashAllowlist,
733
+ settingsManager,
734
+ lspService,
735
+ mcpManager,
736
+ flushMemory,
737
+ runMemoryCompaction,
738
+ runMemorySummary,
739
+ runMemoryRefresh,
740
+ getThemeMode: () => themeMode,
741
+ getResolvedTheme: () => themeResolved,
742
+ setThemeMode: applyThemeMode,
743
+ });
744
+ if (handled && result) {
745
+ addMessage("assistant", result);
746
+ }
747
+ }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, openPicker, safeRegistry, sessionManager]);
748
+ const handleKeySubmit = useCallback((key) => {
749
+ const targetId = keyProviderId || safeRegistry.getDefault()?.id;
750
+ if (!targetId) {
751
+ addMessage("error", "No provider selected.");
752
+ closePicker();
753
+ setKeyProviderId(null);
754
+ return;
755
+ }
756
+ safeRegistry.updateProviderKey(targetId, key);
757
+ const p = safeRegistry.getConfigured().find((x) => x.id === targetId);
758
+ if (p && createProvider) {
759
+ agent.setProvider(createProvider(targetId, key, p.baseURL));
760
+ agent.providerId = targetId;
761
+ }
762
+ addMessage("assistant", `API key updated for ${p?.name || targetId} to ${maskKey(key)}.`);
763
+ closePicker();
764
+ setKeyProviderId(null);
765
+ }, [addMessage, agent, closePicker, createProvider, keyProviderId, safeRegistry]);
766
+ const handleSubmit = useCallback(async (payload) => {
767
+ const normalized = typeof payload === "string" ? { text: payload, images: [] } : payload;
768
+ const input = normalized.text;
769
+ const displayInput = normalized.displayText ?? input;
770
+ const images = normalized.images;
771
+ if (!input.trim() && images.length === 0)
772
+ return;
773
+ const runAgentInput = async (actualInput, displayInput, attachedImages = []) => {
774
+ const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
775
+ const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
776
+ if (!hasActiveProvider) {
777
+ addMessage("error", "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.");
778
+ return;
779
+ }
780
+ if (!agent.model) {
781
+ addMessage("error", "No model selected. Use /model after /login or provider setup.");
782
+ return;
783
+ }
784
+ const displayContent = attachedImages.length > 0
785
+ ? `${displayInput}${displayInput ? "\n" : ""}${attachedImages
786
+ .map((img, i) => `[image${attachedImages.length > 1 ? ` ${i + 1}` : ""}: ${img.filename ?? "clipboard"} · ${Math.max(1, Math.round(img.bytes / 1024))}KB]`)
787
+ .join(" ")}`
788
+ : displayInput;
789
+ updateDisplayMessages((prev) => [
790
+ ...prev,
791
+ withMessageKey({ role: "user", content: displayContent }),
792
+ ]);
793
+ setIsRunning(true);
794
+ runStartRef.current = Date.now();
795
+ setStreamingContent("");
796
+ setStreamingReasoning("");
797
+ setStreamingTools([]);
798
+ setStreamingParts([]);
799
+ let assistantContent = "";
800
+ let assistantReasoning = "";
801
+ const toolCalls = [];
802
+ const assistantParts = [];
803
+ const abortController = new AbortController();
804
+ activeAbortRef.current = abortController;
805
+ const syncStreamingParts = () => {
806
+ setStreamingParts(snapshotDisplayParts(assistantParts));
807
+ };
808
+ const hasAssistantOutput = () => (!!assistantContent ||
809
+ !!assistantReasoning ||
810
+ toolCalls.length > 0 ||
811
+ assistantParts.length > 0);
812
+ const commitAssistantMessage = (taskElapsedMs) => {
813
+ if (!hasAssistantOutput())
814
+ return;
815
+ const currentParts = snapshotDisplayParts(assistantParts);
816
+ const currentToolCalls = [...toolCalls];
817
+ const partContent = assistantContent || contentFromParts(currentParts);
818
+ const partToolCalls = currentToolCalls.length > 0
819
+ ? currentToolCalls
820
+ : toolCallsFromParts(currentParts);
821
+ const msg = {
822
+ key: nextDisplayMessageKey("asst"),
823
+ role: "assistant",
824
+ content: partContent,
825
+ };
826
+ if (assistantReasoning) {
827
+ msg.reasoning = assistantReasoning;
828
+ }
829
+ if (partToolCalls.length > 0) {
830
+ msg.toolCalls = partToolCalls;
831
+ }
832
+ if (currentParts.length > 0) {
833
+ msg.parts = currentParts;
834
+ }
835
+ if (taskElapsedMs !== undefined && Number.isFinite(taskElapsedMs) && taskElapsedMs > 0) {
836
+ msg.taskElapsedMs = taskElapsedMs;
837
+ }
838
+ updateDisplayMessages((prev) => [...prev, msg]);
839
+ };
840
+ const clearAssistantStream = () => {
841
+ setStreamingContent("");
842
+ setStreamingReasoning("");
843
+ setStreamingTools([]);
844
+ setStreamingParts([]);
845
+ assistantContent = "";
846
+ assistantReasoning = "";
847
+ toolCalls.length = 0;
848
+ assistantParts.length = 0;
849
+ };
850
+ const flushAssistantStaticChunk = () => {
851
+ if (toolCalls.some((toolCall) => toolCall.result === undefined)) {
852
+ return false;
853
+ }
854
+ const splitIndex = findStreamingStaticFlushIndex(assistantContent);
855
+ if (splitIndex <= 0)
856
+ return false;
857
+ const { flushedParts, remainingParts } = splitDisplayPartsAtTextOffset(assistantParts, splitIndex);
858
+ const flushedContent = contentFromParts(flushedParts);
859
+ const flushedToolCalls = toolCallsFromParts(flushedParts);
860
+ if (!flushedContent && flushedToolCalls.length === 0)
861
+ return false;
862
+ const msg = {
863
+ key: nextDisplayMessageKey("asst"),
864
+ role: "assistant",
865
+ content: flushedContent,
866
+ };
867
+ if (assistantReasoning) {
868
+ msg.reasoning = assistantReasoning;
869
+ assistantReasoning = "";
870
+ setStreamingReasoning("");
871
+ }
872
+ if (flushedToolCalls.length > 0) {
873
+ msg.toolCalls = flushedToolCalls;
874
+ }
875
+ if (flushedParts.length > 0) {
876
+ msg.parts = flushedParts;
877
+ }
878
+ updateDisplayMessages((prev) => [...prev, msg]);
879
+ assistantParts.splice(0, assistantParts.length, ...remainingParts);
880
+ assistantContent = contentFromParts(assistantParts);
881
+ const remainingToolCalls = toolCallsFromParts(assistantParts);
882
+ toolCalls.splice(0, toolCalls.length, ...remainingToolCalls);
883
+ setStreamingContent(assistantContent);
884
+ setStreamingTools([...toolCalls]);
885
+ syncStreamingParts();
886
+ return true;
887
+ };
888
+ try {
889
+ for await (const event of agent.run(actualInput, args.cwd, { abortSignal: abortController.signal })) {
890
+ switch (event.type) {
891
+ case "text_delta":
892
+ assistantContent += event.content;
893
+ appendTextPart(assistantParts, event.content);
894
+ if (!flushAssistantStaticChunk()) {
895
+ setStreamingContent(assistantContent);
896
+ syncStreamingParts();
897
+ }
898
+ break;
899
+ case "reasoning_delta":
900
+ assistantReasoning += event.content;
901
+ setStreamingReasoning(assistantReasoning);
902
+ break;
903
+ case "tool_call_start": {
904
+ // The LLM has begun emitting this tool call. Args are still
905
+ // streaming — render an empty-args placeholder so the user
906
+ // sees the tool the moment it appears in the assistant
907
+ // response, not after the full arg payload finishes.
908
+ if (!toolCalls.some((t) => t.id === event.id)) {
909
+ const toolCall = {
910
+ id: event.id,
911
+ name: event.name,
912
+ args: {},
913
+ startedAt: Date.now(),
914
+ };
915
+ toolCalls.push(toolCall);
916
+ appendToolPart(assistantParts, toolCall);
917
+ setStreamingTools([...toolCalls]);
918
+ syncStreamingParts();
919
+ }
920
+ break;
921
+ }
922
+ case "tool_call_delta": {
923
+ // Best-effort parse of the partial argument JSON to extract
924
+ // identifying fields (path, command, content, …). The buffer
925
+ // is incomplete JSON during streaming, so fall back to regex
926
+ // peeks on common string fields.
927
+ const tc = toolCalls.find((t) => t.id === event.id);
928
+ if (tc) {
929
+ tc.args = parsePartialArgs(event.arguments, tc.args);
930
+ setStreamingTools([...toolCalls]);
931
+ syncStreamingParts();
932
+ }
933
+ break;
934
+ }
935
+ case "tool_call_end": {
936
+ // Provider signaled args streaming is complete; agent will
937
+ // emit tool_start next. We don't need to do anything visual
938
+ // here — the placeholder is already in place and tool_start
939
+ // will refresh it with the canonical parsed args.
940
+ break;
941
+ }
942
+ case "tool_start": {
943
+ // Tool is about to execute. Upgrade the placeholder created
944
+ // by tool_call_start (or append if upstream skipped the
945
+ // streaming path).
946
+ const existing = toolCalls.find((t) => t.id === event.id);
947
+ if (existing) {
948
+ existing.args = event.args;
949
+ existing.startedAt = existing.startedAt ?? Date.now();
950
+ }
951
+ else {
952
+ const toolCall = {
953
+ id: event.id,
954
+ name: event.name,
955
+ args: event.args,
956
+ startedAt: Date.now(),
957
+ };
958
+ toolCalls.push(toolCall);
959
+ appendToolPart(assistantParts, toolCall);
960
+ }
961
+ setStreamingTools([...toolCalls]);
962
+ syncStreamingParts();
963
+ break;
964
+ }
965
+ case "tool_end": {
966
+ const tc = toolCalls.find((t) => t.id === event.id);
967
+ if (tc) {
968
+ tc.result = event.result.content;
969
+ tc.isError = event.result.isError;
970
+ tc.metadata = event.result.metadata;
971
+ setStreamingTools([...toolCalls]);
972
+ syncStreamingParts();
973
+ }
974
+ break;
975
+ }
976
+ case "tool_update": {
977
+ const tc = toolCalls.find((t) => t.id === event.id);
978
+ if (tc) {
979
+ tc.metadata = mergeToolMetadata(tc.metadata, event.update.metadata);
980
+ if (event.update.message) {
981
+ tc.result = event.update.message;
982
+ }
983
+ tc.isError = event.update.status === "failed"
984
+ || event.update.status === "blocked"
985
+ || event.update.status === "cancelled";
986
+ setStreamingTools([...toolCalls]);
987
+ syncStreamingParts();
988
+ }
989
+ break;
990
+ }
991
+ case "todos_updated": {
992
+ setTodos(event.todos);
993
+ break;
994
+ }
995
+ case "mode_changed": {
996
+ setPermissionMode(event.mode);
997
+ sessionManager?.appendMarker("mode_switch", event.mode);
998
+ break;
999
+ }
1000
+ case "turn_end": {
1001
+ if (event.usage) {
1002
+ setUsageTotals((totals) => ({
1003
+ prompt: totals.prompt + event.usage.promptTokens,
1004
+ completion: totals.completion + event.usage.completionTokens,
1005
+ }));
1006
+ }
1007
+ if (event.willContinue) {
1008
+ syncStreamingParts();
1009
+ break;
1010
+ }
1011
+ commitAssistantMessage(runStartRef.current ? Date.now() - runStartRef.current : undefined);
1012
+ clearAssistantStream();
1013
+ break;
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ catch (err) {
1019
+ commitAssistantMessage();
1020
+ if (err instanceof AgentAbortError || err?.name === "AbortError") {
1021
+ updateDisplayMessages((prev) => [
1022
+ ...prev,
1023
+ withMessageKey({ role: "assistant", content: "Cancelled." }),
1024
+ ]);
1025
+ }
1026
+ else {
1027
+ updateDisplayMessages((prev) => [
1028
+ ...prev,
1029
+ withMessageKey({ role: "error", content: err.message }),
1030
+ ]);
1031
+ }
1032
+ }
1033
+ finally {
1034
+ if (activeAbortRef.current === abortController)
1035
+ activeAbortRef.current = null;
1036
+ setIsRunning(false);
1037
+ runStartRef.current = null;
1038
+ setStreamingContent("");
1039
+ setStreamingReasoning("");
1040
+ setStreamingTools([]);
1041
+ setStreamingParts([]);
1042
+ }
1043
+ };
1044
+ // Slash commands and skill invocations drop any attached images —
1045
+ // they're meant for pure command routing.
1046
+ if (displayInput.startsWith("/")) {
1047
+ // Fast-path `/quit` and `/exit` before slash-registry / skill
1048
+ // resolution. This guarantees a literal "/quit" always exits even if
1049
+ // a skill or alias of the same name is later registered. The
1050
+ // canonical handler still lives in slash-commands/commands.ts so
1051
+ // `/help` and the slash menu can list it; both paths end up calling
1052
+ // requestExit().
1053
+ if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
1054
+ requestExit();
1055
+ return;
1056
+ }
1057
+ const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
1058
+ if (skillInvocation) {
1059
+ await runAgentInput(skillInvocation.actualPrompt, displayInput);
1060
+ return;
1061
+ }
1062
+ const { handled, result, inject } = await slashRegistry.execute(input, {
1063
+ agent,
1064
+ addMessage,
1065
+ clearMessages,
1066
+ cwd: args.cwd,
1067
+ exit: () => { requestExit(); },
1068
+ sessionManager,
1069
+ createProvider: createProvider ?? (() => {
1070
+ throw new Error("Provider creation not available");
1071
+ }),
1072
+ openPicker,
1073
+ openFeedback,
1074
+ registry: safeRegistry,
1075
+ skillRegistry: safeSkillRegistry,
1076
+ bashAllowlist,
1077
+ settingsManager,
1078
+ lspService,
1079
+ mcpManager,
1080
+ flushMemory,
1081
+ runMemoryCompaction,
1082
+ runMemorySummary,
1083
+ runMemoryRefresh,
1084
+ getThemeMode: () => themeMode,
1085
+ getResolvedTheme: () => themeResolved,
1086
+ setThemeMode: applyThemeMode,
1087
+ });
1088
+ if (handled) {
1089
+ if (agent.mode !== permissionMode) {
1090
+ setPermissionMode(agent.mode);
1091
+ }
1092
+ if (result) {
1093
+ // `/compact` rewrites agent.messages, so the Ink transcript needs to
1094
+ // be rebuilt from the new agent state before appending the summary
1095
+ // card; otherwise the pre-compaction history would keep rendering.
1096
+ if (result.startsWith("✓ Compaction complete")) {
1097
+ const summary = latestCompactionSummary(agent.messages);
1098
+ updateDisplayMessages(() => [
1099
+ ...reconstructDisplayMessages(agent.messages),
1100
+ {
1101
+ role: "assistant",
1102
+ content: result,
1103
+ syntheticKind: "ui_compact_summary",
1104
+ compactionSummary: summary,
1105
+ },
1106
+ ]);
1107
+ }
1108
+ else {
1109
+ addMessage("assistant", result);
1110
+ }
1111
+ }
1112
+ if (inject) {
1113
+ await runAgentInput(inject, displayInput);
1114
+ }
1115
+ return;
1116
+ }
1117
+ }
1118
+ const expansion = await expandAtMentions(input, args.cwd);
1119
+ if (expansion.missing.length > 0) {
1120
+ addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
1121
+ }
1122
+ for (const skip of expansion.skipped) {
1123
+ addMessage("error", `Skipped @${skip.path}: ${skip.reason}`);
1124
+ }
1125
+ const agentInput = images.length > 0
1126
+ ? [
1127
+ ...(expansion.text ? [{ type: "text", text: expansion.text }] : []),
1128
+ ...images.map((img) => ({
1129
+ type: "image_url",
1130
+ image_url: { url: img.dataUrl },
1131
+ })),
1132
+ ]
1133
+ : expansion.text;
1134
+ await runAgentInput(agentInput, displayInput, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
1135
+ }, [addMessage, agent, args.cwd, openPicker, createProvider, safeRegistry, safeSkillRegistry, updateDisplayMessages]);
1136
+ const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
1137
+ const keyTarget = keyProviderId
1138
+ ? safeRegistry.getConfigured().find((p) => p.id === keyProviderId)
1139
+ : safeRegistry.getDefault();
1140
+ // Surface a pending approval as an inline badge on the matching tool row.
1141
+ // ApprovalRequest does not carry a toolCallId today; matching is loose by
1142
+ // type + the most identifying arg (path/command).
1143
+ const approvalHint = pendingApproval
1144
+ ? (() => {
1145
+ const r = pendingApproval.request;
1146
+ if (r.type === "bash")
1147
+ return { toolName: "bash", command: r.command };
1148
+ if (r.type === "edit")
1149
+ return { toolName: "edit", path: r.path };
1150
+ if (r.type === "patch")
1151
+ return { toolName: "edit", path: r.paths[0] ?? r.path };
1152
+ if (r.type === "write")
1153
+ return { toolName: "write", path: r.path };
1154
+ return null;
1155
+ })()
1156
+ : null;
1157
+ const mcpStates = mcpManager?.getStates() ?? [];
1158
+ const mcpConnectedCount = mcpStates.filter((state) => state.status.kind === "connected").length;
1159
+ const hasAgentsFile = useMemo(() => existsSync(join(args.cwd, "AGENTS.md")) || existsSync(join(args.cwd, ".bubble", "AGENTS.md")), [args.cwd]);
1160
+ const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, modelLabel: agent.model ? displayModel(agent.model) : undefined, cwd: friendlyCwd(args.cwd), tips: buildTips(agent, safeRegistry), skillsCount: safeSkillRegistry.summaries().length, mcpConnectedCount: mcpConnectedCount, mcpTotalCount: mcpStates.length, hasAgentsFile: hasAgentsFile })) : null;
1161
+ const hasTranscript = messages.some(hasVisibleDisplayMessage);
1162
+ const hasStreamingTurn = !!(streamingContent ||
1163
+ streamingReasoning ||
1164
+ streamingTools.length > 0 ||
1165
+ streamingParts.length > 0);
1166
+ const showHomeSurface = !!(showWelcome &&
1167
+ !hasTranscript &&
1168
+ !hasStreamingTurn &&
1169
+ !isRunning &&
1170
+ !pickerMode &&
1171
+ !pendingPlan &&
1172
+ !pendingApproval &&
1173
+ !pendingQuestion &&
1174
+ !pendingFeedback &&
1175
+ !isExiting);
1176
+ const footerData = buildFooterData({
1177
+ cwd: args.cwd,
1178
+ providerId: agent.providerId || safeRegistry.getDefault()?.id || "unknown",
1179
+ model: displayModel(agent.model) || "no model",
1180
+ thinkingLevel,
1181
+ showThinking: getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2,
1182
+ mode: permissionMode,
1183
+ usageTotals,
1184
+ verboseTrace,
1185
+ });
1186
+ const composerNode = (_jsx(InputBox, { onSubmit: handleSubmit, disabled: isRunning || !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }));
1187
+ return (_jsx(ThemeProvider, { value: palette, children: _jsx("box", { style: { flexDirection: "column", height: terminalRows, backgroundColor: palette.background }, children: showHomeSurface ? (_jsx(HomeSurface, { terminalColumns: terminalColumns, terminalRows: terminalRows, modelLabel: agent.model ? displayModel(agent.model) : undefined, cwd: friendlyCwd(args.cwd), tips: buildTips(agent, safeRegistry), skillsCount: safeSkillRegistry.summaries().length, mcpConnectedCount: mcpConnectedCount, mcpTotalCount: mcpStates.length, hasAgentsFile: hasAgentsFile, composer: composerNode })) : (_jsxs(_Fragment, { children: [_jsxs("box", { style: { flexDirection: "column", paddingLeft: 1, paddingRight: 1, paddingTop: 1, flexShrink: 0 }, children: [_jsx(SessionHeader, { terminalColumns: terminalColumns, cwd: friendlyCwd(args.cwd), mode: permissionMode, model: displayModel(agent.model) || "no model" }), _jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: showHomeSurface ? null : welcomeBannerNode }, clearEpoch), pickerMode === "model" && (_jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker })), pickerMode === "provider" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1188
+ .filter((p) => isUserVisibleProvider(p.id))
1189
+ .map((p) => {
1190
+ const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1191
+ const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1192
+ return {
1193
+ id: p.id,
1194
+ name: `${p.name} [${configuredLabel}]`,
1195
+ enabled: true,
1196
+ };
1197
+ }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker })), pickerMode === "provider-add" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1198
+ .filter((p) => isUserVisibleProvider(p.id))
1199
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" })), pickerMode === "login" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1200
+ .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1201
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" })), pickerMode === "logout" && (_jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1202
+ .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1203
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" })), pickerMode === "key" && keyTarget && (_jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1204
+ closePicker();
1205
+ setKeyProviderId(null);
1206
+ } })), pickerMode === "skill" && (_jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1207
+ fillComposer(`/${name} `);
1208
+ closePicker();
1209
+ }, onCancel: closePicker })), pickerMode === "feishu-setup" && (_jsx(FeishuSetupPicker, { onComplete: (summary) => {
1210
+ closePicker();
1211
+ addMessage("assistant", summary);
1212
+ }, onCancel: () => {
1213
+ closePicker();
1214
+ addMessage("assistant", "已取消 Feishu setup。");
1215
+ } }))] }), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx("box", { style: { paddingLeft: 1, paddingRight: 1, flexShrink: 0 }, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx("box", { style: { paddingLeft: 1, paddingRight: 1, flexShrink: 0 }, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1216
+ const resolve = pendingPlan.resolve;
1217
+ setPendingPlan(null);
1218
+ resolve({ action: "approve", plan: finalPlan });
1219
+ }, onReject: (reason) => {
1220
+ const resolve = pendingPlan.resolve;
1221
+ setPendingPlan(null);
1222
+ resolve({ action: "reject", reason });
1223
+ } }) })), pendingApproval && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx("box", { style: { paddingLeft: 1, paddingRight: 1, flexShrink: 0 }, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
1224
+ const resolve = pendingApproval.resolve;
1225
+ setPendingApproval(null);
1226
+ resolve(decision);
1227
+ }, onAllowBashPrefix: (prefix) => {
1228
+ bashAllowlist?.add(prefix);
1229
+ } }) })), pendingQuestion && !pickerMode && !pendingPlan && !pendingApproval && !pendingFeedback && (_jsx("box", { style: { paddingLeft: 1, paddingRight: 1, flexShrink: 0 }, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
1230
+ questionController?.reply(pendingQuestion.id, answers);
1231
+ setPendingQuestion(null);
1232
+ }, onCancel: () => {
1233
+ questionController?.reject(pendingQuestion.id);
1234
+ setPendingQuestion(null);
1235
+ } }) })), pendingFeedback && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx("box", { style: { paddingLeft: 1, paddingRight: 1, flexShrink: 0 }, children: _jsx(FeedbackDialog, { base: pendingFeedback.base, initialDescription: pendingFeedback.initialDescription, onDismiss: () => setPendingFeedback(null), onResult: (result) => {
1236
+ if (result.kind === "success") {
1237
+ addMessage("assistant", `Feedback submitted: ${result.url}`);
1238
+ }
1239
+ else if (result.kind === "error") {
1240
+ addMessage("error", `Feedback failed: ${result.message}`);
1241
+ }
1242
+ } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx("box", { style: { paddingLeft: 1, paddingRight: 1, paddingBottom: 1, flexShrink: 0 }, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick }) })), !isExiting && !pickerMode && (_jsx("box", { style: { paddingBottom: 1, flexShrink: 0 }, children: composerNode })), !isExiting && (_jsx("box", { style: { flexShrink: 0 }, children: _jsx(FooterBar, { data: footerData }) }))] })) }) }));
1243
+ }
1244
+ function SessionHeader({ terminalColumns, cwd, mode, model, }) {
1245
+ const theme = useTheme();
1246
+ const width = Math.max(20, terminalColumns - 4);
1247
+ const modeLabel = mode === "plan" ? "PLAN" : mode === "bypassPermissions" ? "BYPASS" : "BUILD";
1248
+ const left = ` ${cwd}`;
1249
+ const right = `${modeLabel} · ${model} `;
1250
+ const filler = "─".repeat(Math.max(1, width - left.length - right.length));
1251
+ return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx("text", { fg: theme.textMuted, content: left }), _jsx("text", { fg: theme.textDim, content: filler }), _jsx("text", { fg: mode === "bypassPermissions" ? theme.warning : theme.accent, attributes: 1, content: right })] }));
1252
+ }
1253
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1254
+ const GENERIC_PHRASES = [
1255
+ "mapping the workspace",
1256
+ "reading the room",
1257
+ "following the threads",
1258
+ "connecting the pieces",
1259
+ "sorting the context",
1260
+ "scanning the structure",
1261
+ "shaping the next step",
1262
+ "gathering signal",
1263
+ "checking the edges",
1264
+ "lining up the answer",
1265
+ "tracing the flow",
1266
+ "building the picture",
1267
+ "walking the graph",
1268
+ "collecting the clues",
1269
+ "framing the problem",
1270
+ "locating the source",
1271
+ "resolving the shape",
1272
+ "untangling the state",
1273
+ "comparing the paths",
1274
+ "narrowing the target",
1275
+ "tracking the changes",
1276
+ "reading the patterns",
1277
+ "weighing the options",
1278
+ "assembling the context",
1279
+ "following the signal",
1280
+ "checking the assumptions",
1281
+ "aligning the details",
1282
+ "testing the shape",
1283
+ "pulling the thread",
1284
+ "cleaning the edges",
1285
+ "refining the draft",
1286
+ "verifying the route",
1287
+ "making sense of it",
1288
+ "looking for leverage",
1289
+ "stitching the answer",
1290
+ "holding the thread",
1291
+ "distilling the noise",
1292
+ "finding the seam",
1293
+ "reading between the lines",
1294
+ "preparing the response",
1295
+ ];
1296
+ const TOOL_TARGET_PHRASES = {
1297
+ read: "reading files",
1298
+ write: "writing changes",
1299
+ edit: "patching files",
1300
+ grep: "searching the codebase",
1301
+ glob: "scanning paths",
1302
+ ls: "listing directories",
1303
+ bash: "running command",
1304
+ web_search: "searching the web",
1305
+ web_fetch: "fetching a page",
1306
+ task: "spawning subagent",
1307
+ };
1308
+ function formatTokensApprox(chars) {
1309
+ const tokens = Math.max(0, Math.round(chars / 4));
1310
+ if (tokens < 1000)
1311
+ return `${tokens}`;
1312
+ if (tokens < 10000)
1313
+ return `${(tokens / 1000).toFixed(1)}k`;
1314
+ return `${Math.round(tokens / 1000)}k`;
1315
+ }
1316
+ function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, }) {
1317
+ void nowTick;
1318
+ const theme = useTheme();
1319
+ const [frameIndex, setFrameIndex] = useState(0);
1320
+ const [idlePhrase, setIdlePhrase] = useState(() => GENERIC_PHRASES[0]);
1321
+ // Frame timer is independent of the agent state — keeps animation smooth.
1322
+ useEffect(() => {
1323
+ const t = setInterval(() => {
1324
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
1325
+ }, 100);
1326
+ return () => clearInterval(t);
1327
+ }, []);
1328
+ // Determine state: active tool > streaming text > streaming reasoning > idle
1329
+ const activeTool = [...tools].reverse().find((t) => !t.result);
1330
+ const state = activeTool
1331
+ ? "tool"
1332
+ : hasStreamingText
1333
+ ? "text"
1334
+ : hasStreamingReasoning
1335
+ ? "reasoning"
1336
+ : "idle";
1337
+ // Rotate idle phrases on a slower cadence; only matters in the idle state.
1338
+ useEffect(() => {
1339
+ if (state !== "idle")
1340
+ return;
1341
+ const t = setInterval(() => {
1342
+ setIdlePhrase((current) => {
1343
+ const candidates = GENERIC_PHRASES.filter((item) => item !== current);
1344
+ return candidates[Math.floor(Math.random() * candidates.length)] || current;
1345
+ });
1346
+ }, 1500);
1347
+ return () => clearInterval(t);
1348
+ }, [state]);
1349
+ let phrase;
1350
+ if (state === "tool" && activeTool) {
1351
+ phrase =
1352
+ TOOL_TARGET_PHRASES[activeTool.name] || `running ${activeTool.name}`;
1353
+ }
1354
+ else if (state === "text") {
1355
+ phrase = "writing the response";
1356
+ }
1357
+ else if (state === "reasoning") {
1358
+ phrase = "working through the request";
1359
+ }
1360
+ else {
1361
+ phrase = idlePhrase;
1362
+ }
1363
+ const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
1364
+ return (_jsxs("box", { children: [_jsx("text", { fg: theme.accent, children: SPINNER_FRAMES[frameIndex] }), _jsxs("text", { fg: theme.muted, children: [" ", phrase, " "] }), _jsxs("text", { fg: theme.muted, children: ["(", tokenText ? `${tokenText} · ` : "", "esc\u00B7esc to interrupt)"] })] }));
1365
+ }