@bubblebrain-ai/bubble 0.0.23 → 0.0.25

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 (168) hide show
  1. package/README.md +1 -1
  2. package/dist/config.d.ts +3 -0
  3. package/dist/config.js +22 -6
  4. package/dist/goal/command.d.ts +20 -0
  5. package/dist/goal/command.js +71 -0
  6. package/dist/goal/engine.d.ts +33 -0
  7. package/dist/goal/engine.js +65 -0
  8. package/dist/goal/format.d.ts +18 -0
  9. package/dist/goal/format.js +112 -0
  10. package/dist/goal/prompts.d.ts +13 -0
  11. package/dist/goal/prompts.js +84 -0
  12. package/dist/goal/store.d.ts +64 -0
  13. package/dist/goal/store.js +174 -0
  14. package/dist/goal/tools.d.ts +10 -0
  15. package/dist/goal/tools.js +70 -0
  16. package/dist/goal/usage.d.ts +2 -0
  17. package/dist/goal/usage.js +3 -0
  18. package/dist/main.js +29 -42
  19. package/dist/model-catalog.js +11 -0
  20. package/dist/provider-transform.js +17 -0
  21. package/dist/provider.js +20 -5
  22. package/dist/session-types.d.ts +3 -0
  23. package/dist/tools/index.d.ts +3 -0
  24. package/dist/tools/index.js +2 -0
  25. package/dist/tui/detect-theme.d.ts +1 -0
  26. package/dist/tui/detect-theme.js +23 -0
  27. package/dist/tui/image-display.d.ts +13 -0
  28. package/dist/tui/image-display.js +49 -0
  29. package/dist/tui/input-history.d.ts +37 -6
  30. package/dist/tui/input-history.js +194 -23
  31. package/dist/tui/model-switch.d.ts +42 -0
  32. package/dist/tui/model-switch.js +55 -0
  33. package/dist/tui-ink/app.d.ts +32 -2
  34. package/dist/tui-ink/app.js +1360 -522
  35. package/dist/tui-ink/approval/select.js +10 -0
  36. package/dist/tui-ink/detect-theme.d.ts +1 -2
  37. package/dist/tui-ink/detect-theme.js +1 -87
  38. package/dist/tui-ink/display-history.d.ts +1 -0
  39. package/dist/tui-ink/display-history.js +11 -0
  40. package/dist/tui-ink/feedback-dialog.js +10 -0
  41. package/dist/tui-ink/feishu-setup-picker.js +10 -0
  42. package/dist/tui-ink/footer.d.ts +1 -0
  43. package/dist/tui-ink/footer.js +8 -2
  44. package/dist/tui-ink/input-box.d.ts +70 -9
  45. package/dist/tui-ink/input-box.js +354 -120
  46. package/dist/tui-ink/input-history.d.ts +1 -16
  47. package/dist/tui-ink/input-history.js +1 -79
  48. package/dist/tui-ink/input-queue.d.ts +12 -0
  49. package/dist/tui-ink/input-queue.js +17 -0
  50. package/dist/tui-ink/key-events.d.ts +9 -0
  51. package/dist/tui-ink/key-events.js +8 -0
  52. package/dist/tui-ink/markdown.js +1 -1
  53. package/dist/tui-ink/message-list.d.ts +3 -1
  54. package/dist/tui-ink/message-list.js +42 -24
  55. package/dist/tui-ink/model-picker.d.ts +24 -2
  56. package/dist/tui-ink/model-picker.js +224 -20
  57. package/dist/tui-ink/plan-confirm.js +10 -0
  58. package/dist/tui-ink/question-dialog.js +10 -0
  59. package/dist/tui-ink/run.d.ts +11 -0
  60. package/dist/tui-ink/run.js +21 -28
  61. package/dist/tui-ink/session-picker.js +3 -0
  62. package/dist/tui-ink/submit-dedupe.d.ts +5 -0
  63. package/dist/tui-ink/submit-dedupe.js +25 -0
  64. package/dist/tui-ink/terminal-mouse.d.ts +13 -1
  65. package/dist/tui-ink/terminal-mouse.js +63 -21
  66. package/dist/tui-ink/theme.d.ts +6 -3
  67. package/dist/tui-ink/theme.js +10 -4
  68. package/dist/tui-ink/transcript-input.d.ts +8 -0
  69. package/dist/tui-ink/transcript-input.js +9 -0
  70. package/dist/tui-ink/transcript-viewport-math.d.ts +1 -2
  71. package/dist/tui-ink/transcript-viewport-math.js +1 -2
  72. package/dist/tui-ink/welcome.d.ts +1 -0
  73. package/dist/tui-ink/welcome.js +25 -28
  74. package/package.json +1 -5
  75. package/dist/tui/clipboard.d.ts +0 -1
  76. package/dist/tui/clipboard.js +0 -53
  77. package/dist/tui/escape-confirmation.d.ts +0 -15
  78. package/dist/tui/escape-confirmation.js +0 -30
  79. package/dist/tui/global-key-router.d.ts +0 -3
  80. package/dist/tui/global-key-router.js +0 -87
  81. package/dist/tui/markdown-inline.d.ts +0 -22
  82. package/dist/tui/markdown-inline.js +0 -68
  83. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  84. package/dist/tui/markdown-theme-rules.js +0 -164
  85. package/dist/tui/markdown-theme.d.ts +0 -5
  86. package/dist/tui/markdown-theme.js +0 -27
  87. package/dist/tui/opencode-spinner.d.ts +0 -22
  88. package/dist/tui/opencode-spinner.js +0 -216
  89. package/dist/tui/prompt-keybindings.d.ts +0 -42
  90. package/dist/tui/prompt-keybindings.js +0 -35
  91. package/dist/tui/render-signature.d.ts +0 -1
  92. package/dist/tui/render-signature.js +0 -7
  93. package/dist/tui/run.d.ts +0 -65
  94. package/dist/tui/run.js +0 -9934
  95. package/dist/tui/sidebar-mcp.d.ts +0 -31
  96. package/dist/tui/sidebar-mcp.js +0 -62
  97. package/dist/tui/sidebar-state.d.ts +0 -12
  98. package/dist/tui/sidebar-state.js +0 -69
  99. package/dist/tui/streaming-tool-args.d.ts +0 -15
  100. package/dist/tui/streaming-tool-args.js +0 -30
  101. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  102. package/dist/tui/tool-renderers/fallback.js +0 -75
  103. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  104. package/dist/tui/tool-renderers/registry.js +0 -11
  105. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  106. package/dist/tui/tool-renderers/subagent.js +0 -135
  107. package/dist/tui/tool-renderers/types.d.ts +0 -36
  108. package/dist/tui/tool-renderers/types.js +0 -1
  109. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  110. package/dist/tui/tool-renderers/write-preview.js +0 -32
  111. package/dist/tui/tool-renderers/write.d.ts +0 -6
  112. package/dist/tui/tool-renderers/write.js +0 -88
  113. package/dist/tui-opentui/app.d.ts +0 -54
  114. package/dist/tui-opentui/app.js +0 -1371
  115. package/dist/tui-opentui/approval/approval-dialog.d.ts +0 -15
  116. package/dist/tui-opentui/approval/approval-dialog.js +0 -155
  117. package/dist/tui-opentui/approval/diff-view.d.ts +0 -9
  118. package/dist/tui-opentui/approval/diff-view.js +0 -43
  119. package/dist/tui-opentui/approval/select.d.ts +0 -37
  120. package/dist/tui-opentui/approval/select.js +0 -91
  121. package/dist/tui-opentui/detect-theme.d.ts +0 -2
  122. package/dist/tui-opentui/detect-theme.js +0 -87
  123. package/dist/tui-opentui/display-history.d.ts +0 -56
  124. package/dist/tui-opentui/display-history.js +0 -130
  125. package/dist/tui-opentui/edit-diff.d.ts +0 -11
  126. package/dist/tui-opentui/edit-diff.js +0 -57
  127. package/dist/tui-opentui/feedback-dialog.d.ts +0 -21
  128. package/dist/tui-opentui/feedback-dialog.js +0 -164
  129. package/dist/tui-opentui/feishu-setup-picker.d.ts +0 -7
  130. package/dist/tui-opentui/feishu-setup-picker.js +0 -272
  131. package/dist/tui-opentui/file-mentions.d.ts +0 -29
  132. package/dist/tui-opentui/file-mentions.js +0 -174
  133. package/dist/tui-opentui/footer.d.ts +0 -26
  134. package/dist/tui-opentui/footer.js +0 -40
  135. package/dist/tui-opentui/image-paste.d.ts +0 -54
  136. package/dist/tui-opentui/image-paste.js +0 -288
  137. package/dist/tui-opentui/input-box.d.ts +0 -32
  138. package/dist/tui-opentui/input-box.js +0 -462
  139. package/dist/tui-opentui/input-history.d.ts +0 -16
  140. package/dist/tui-opentui/input-history.js +0 -79
  141. package/dist/tui-opentui/markdown.d.ts +0 -66
  142. package/dist/tui-opentui/markdown.js +0 -127
  143. package/dist/tui-opentui/message-list.d.ts +0 -31
  144. package/dist/tui-opentui/message-list.js +0 -131
  145. package/dist/tui-opentui/model-picker.d.ts +0 -63
  146. package/dist/tui-opentui/model-picker.js +0 -450
  147. package/dist/tui-opentui/plan-confirm.d.ts +0 -9
  148. package/dist/tui-opentui/plan-confirm.js +0 -124
  149. package/dist/tui-opentui/question-dialog.d.ts +0 -10
  150. package/dist/tui-opentui/question-dialog.js +0 -110
  151. package/dist/tui-opentui/recent-activity.d.ts +0 -8
  152. package/dist/tui-opentui/recent-activity.js +0 -71
  153. package/dist/tui-opentui/run-session-picker.d.ts +0 -10
  154. package/dist/tui-opentui/run-session-picker.js +0 -28
  155. package/dist/tui-opentui/run.d.ts +0 -38
  156. package/dist/tui-opentui/run.js +0 -48
  157. package/dist/tui-opentui/session-picker.d.ts +0 -12
  158. package/dist/tui-opentui/session-picker.js +0 -120
  159. package/dist/tui-opentui/theme.d.ts +0 -89
  160. package/dist/tui-opentui/theme.js +0 -157
  161. package/dist/tui-opentui/todos.d.ts +0 -9
  162. package/dist/tui-opentui/todos.js +0 -45
  163. package/dist/tui-opentui/trace-groups.d.ts +0 -27
  164. package/dist/tui-opentui/trace-groups.js +0 -455
  165. package/dist/tui-opentui/use-terminal-size.d.ts +0 -4
  166. package/dist/tui-opentui/use-terminal-size.js +0 -5
  167. package/dist/tui-opentui/welcome.d.ts +0 -25
  168. package/dist/tui-opentui/welcome.js +0 -77
@@ -3,18 +3,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useApp, useInput } from "ink";
4
4
  import { AgentAbortError, INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
5
5
  import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
6
+ import { SessionManager } from "../session.js";
6
7
  import { registry as slashRegistry } from "../slash-commands/index.js";
7
8
  import { UserConfig, maskKey } from "../config.js";
8
9
  import { createPastedContentMarker, InputBox, isCtrlCInput, shouldCollapsePastedContent, } from "./input-box.js";
9
10
  import { MessageList } from "./message-list.js";
10
- import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
11
+ import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
11
12
  import { AgentRunInputQueue } from "../agent/input-controller.js";
12
13
  import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
13
- import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
14
+ import { isPrintablePickerInput, ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
14
15
  import { FeishuSetupPicker } from "./feishu-setup-picker.js";
15
16
  import { BUILTIN_PROVIDERS, ProviderRegistry, displayModel, isUserVisibleProvider } from "../provider-registry.js";
16
17
  import { buildSystemPrompt } from "../system-prompt.js";
17
- import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
18
+ import { getAvailableThinkingLevels, normalizeThinkingLevel } from "../provider-transform.js";
18
19
  import { FooterBar, buildFooterData } from "./footer.js";
19
20
  import { SkillRegistry } from "../skills/registry.js";
20
21
  import { parseSkillInvocation } from "../skills/invocation.js";
@@ -28,8 +29,23 @@ import { getNextPermissionMode } from "../permission/mode.js";
28
29
  import { QuestionDialog } from "./question-dialog.js";
29
30
  import { FeedbackDialog } from "./feedback-dialog.js";
30
31
  import { collectFeedback } from "../feedback/collect.js";
31
- import { hasTerminalMouseSequence } from "./terminal-mouse.js";
32
+ import { isKeyReleaseEvent } from "./key-events.js";
33
+ import { errorMessage, formatModelSwitchError, switchAgentModel } from "../tui/model-switch.js";
34
+ import { formatImageUserDisplayText, nextImageDisplayLabelStart } from "../tui/image-display.js";
35
+ import { sanitizeTerminalMouseInput, transcriptScrollLinesFromMouseInput } from "./terminal-mouse.js";
36
+ import { transcriptPageScrollDirection } from "./transcript-input.js";
37
+ import { decideStartingSubmitFingerprint, submitPayloadFingerprint } from "./submit-dedupe.js";
38
+ import { isQueuedInputForCurrentSession, queuedAndPendingDisplayKeys, } from "./input-queue.js";
32
39
  import { TranscriptViewport } from "./transcript-viewport.js";
40
+ import { SessionPicker } from "./session-picker.js";
41
+ import { sessionDisplayName } from "../tui/session-display.js";
42
+ import { parseGoalCommand } from "../goal/command.js";
43
+ import { continuationPrompt, initialPrompt } from "../goal/prompts.js";
44
+ import { shouldContinueGoal, stopReasonNotice } from "../goal/engine.js";
45
+ import { goalCompleteNotice, goalIndicatorLine, goalSummaryText } from "../goal/format.js";
46
+ import { tokenUsageTotal } from "../goal/usage.js";
47
+ import { formatInternalContextBlock } from "../agent/internal-reminder-sanitizer.js";
48
+ import { collectUsageStatsBundle, formatStatsPanelBody, rangeLabel } from "../stats/usage.js";
33
49
  import os from "node:os";
34
50
  function buildTips(agent, registry) {
35
51
  const tips = [];
@@ -55,6 +71,11 @@ function friendlyCwd(cwd) {
55
71
  return "~" + cwd.slice(home.length);
56
72
  return cwd;
57
73
  }
74
+ function truncate(value, max) {
75
+ if (value.length <= max)
76
+ return value;
77
+ return `${value.slice(0, Math.max(0, max - 1))}…`;
78
+ }
58
79
  function reconstructDisplayMessages(agentMessages) {
59
80
  const result = [];
60
81
  for (const m of agentMessages) {
@@ -206,7 +227,38 @@ function withMessageKey(message) {
206
227
  // would make Yoga re-lay-out the transcript for every few bytes of output.
207
228
  // 40ms keeps perceived latency invisible while capping layout work at 25fps.
208
229
  const STREAMING_FLUSH_INTERVAL_MS = 40;
209
- 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, updateNotice, hookController, onExit }) {
230
+ export const INK_LOCAL_SLASH_COMMANDS = [
231
+ {
232
+ name: "thinking",
233
+ description: "Toggle thinking block visibility",
234
+ },
235
+ {
236
+ name: "toggle-thinking",
237
+ description: "Toggle thinking block visibility",
238
+ },
239
+ {
240
+ name: "goal",
241
+ description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
242
+ },
243
+ {
244
+ name: "trace",
245
+ description: "Toggle verbose trace output",
246
+ },
247
+ {
248
+ name: "verbose",
249
+ description: "Toggle verbose trace output",
250
+ },
251
+ {
252
+ name: "debug",
253
+ description: "Toggle verbose trace output",
254
+ },
255
+ {
256
+ name: "write-previews",
257
+ description: "Toggle write preview expansion",
258
+ },
259
+ ];
260
+ 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, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, onExit }) {
261
+ const [sessionManager, setSessionManager] = useState(initialSessionManager);
210
262
  const [themeMode, setThemeMode] = useState(initialThemeMode ?? "auto");
211
263
  // `detectedTheme` is captured once at startup in main.ts. We keep it in state
212
264
  // so future re-detection (e.g. if a user runs `/theme auto` after switching
@@ -224,6 +276,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
224
276
  const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
225
277
  const { exit } = useApp();
226
278
  const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
279
+ const nextImageDisplayLabelStartRef = useRef(nextImageDisplayLabelStart(messages));
227
280
  const [isRunning, setIsRunning] = useState(false);
228
281
  const [streamingContent, setStreamingContent] = useState("");
229
282
  const [streamingReasoning, setStreamingReasoning] = useState("");
@@ -232,26 +285,33 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
232
285
  const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
233
286
  const [permissionMode, setPermissionMode] = useState(agent.mode);
234
287
  const [todos, setTodos] = useState(() => agent.getTodos());
288
+ const [goalLine, setGoalLine] = useState("");
289
+ const [currentUpdateNotice, setCurrentUpdateNotice] = useState(updateNotice);
235
290
  const [pendingPlan, setPendingPlan] = useState(null);
236
291
  const [pendingApproval, setPendingApproval] = useState(null);
237
292
  const [pendingQuestion, setPendingQuestion] = useState(null);
238
293
  const [pendingFeedback, setPendingFeedback] = useState(null);
239
294
  const [pickerMode, setPickerMode] = useState(null);
295
+ const [statsPanel, setStatsPanel] = useState(null);
240
296
  const [cursorResetEpoch, setCursorResetEpoch] = useState(0);
241
297
  const [composerDraft, setComposerDraft] = useState(null);
242
298
  const [keyProviderId, setKeyProviderId] = useState(null);
299
+ const [showThinking, setShowThinking] = useState(false);
300
+ const [expandedToolOutput, setExpandedToolOutput] = useState(false);
243
301
  const [verboseTrace, setVerboseTrace] = useState(false);
302
+ const [sidebarMode, setSidebarMode] = useState("collapsed");
244
303
  const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
245
304
  const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
246
305
  const showWelcome = shouldShowWelcomeBanner({
247
306
  messages,
248
307
  startedWithVisibleHistory: startedWithVisibleHistoryRef.current,
249
308
  });
309
+ const showWelcomeRef = useRef(showWelcome);
250
310
  const activeAbortRef = useRef(null);
251
311
  const exitRequestedRef = useRef(false);
252
312
  const sessionStartRef = useRef(Date.now());
253
313
  const viewportRef = useRef(null);
254
- // Steer/queue while the agent runs (parity with the OpenTUI composer):
314
+ // Steer/queue while the agent runs:
255
315
  // Enter steers the current run via the agent's input controller; Tab (or an
256
316
  // ineligible input) queues for the next turn. Both render placeholder user
257
317
  // rows whose badge tracks the input's lifecycle.
@@ -260,6 +320,8 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
260
320
  const queuedInputsRef = useRef([]);
261
321
  const [pendingSteerCount, setPendingSteerCount] = useState(0);
262
322
  const [queuedCount, setQueuedCount] = useState(0);
323
+ const startingSubmitFingerprintRef = useRef(null);
324
+ const [startingSubmitFingerprint, setStartingSubmitFingerprint] = useState(null);
263
325
  const nextRunIdRef = useRef(0);
264
326
  // Set true the moment /quit is invoked so we can hide dynamic UI (composer,
265
327
  // waiting indicator, footer) before Ink snapshots its final frame into the
@@ -286,6 +348,46 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
286
348
  cwd: args.cwd,
287
349
  skillPaths: userConfig.getSkillPaths(),
288
350
  });
351
+ useEffect(() => {
352
+ setCurrentUpdateNotice(updateNotice);
353
+ }, [updateNotice]);
354
+ useEffect(() => {
355
+ showWelcomeRef.current = showWelcome;
356
+ }, [showWelcome]);
357
+ useEffect(() => {
358
+ if (!goalStore)
359
+ return;
360
+ let persistSuspended = false;
361
+ const persistGoal = (goal) => {
362
+ if (!sessionManager)
363
+ return;
364
+ try {
365
+ const metadata = sessionManager.getMetadata();
366
+ sessionManager.setMetadata({ ...metadata, goal: goal ?? undefined });
367
+ }
368
+ catch {
369
+ // Goal persistence is best-effort; never break the run loop over it.
370
+ }
371
+ };
372
+ const unsubscribe = goalStore.onChange((goal) => {
373
+ setGoalLine(goal ? goalIndicatorLine(goal) : "");
374
+ if (!persistSuspended)
375
+ persistGoal(goal);
376
+ });
377
+ const persisted = sessionManager?.getMetadata().goal;
378
+ if (persisted) {
379
+ persistSuspended = true;
380
+ goalStore.loadFrom(persisted.status === "active" ? { ...persisted, status: "paused" } : persisted);
381
+ persistSuspended = false;
382
+ }
383
+ else {
384
+ persistSuspended = true;
385
+ goalStore.loadFrom(undefined);
386
+ persistSuspended = false;
387
+ setGoalLine("");
388
+ }
389
+ return unsubscribe;
390
+ }, [goalStore, sessionManager]);
289
391
  const requestExit = useCallback(() => {
290
392
  if (exitRequestedRef.current)
291
393
  return;
@@ -415,26 +517,54 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
415
517
  }));
416
518
  }, [agent, args.cwd, safeRegistry, safeSkillRegistry]);
417
519
  useInput((input, key) => {
520
+ if (isKeyReleaseEvent(key))
521
+ return;
418
522
  if (isCtrlCInput(input, key)) {
419
523
  requestExit();
420
524
  return;
421
525
  }
422
- // Mouse reporting is off (native drag-select/copy works directly), so no
423
- // SGR wheel events arrive — wheel scrolling reaches the app as Up/Down
424
- // arrows via the terminal's alternate-scroll mode, classified in the
425
- // composer. Defensively drop any stray mouse report bytes.
426
- if (hasTerminalMouseSequence(input))
526
+ const overlayActive = !!(pickerMode || pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || statsPanel);
527
+ const mouseInput = sanitizeTerminalMouseInput(input);
528
+ if (mouseInput.wheelDirections.length > 0) {
529
+ for (const lines of transcriptScrollLinesFromMouseInput(mouseInput, { overlayActive })) {
530
+ viewportRef.current?.scrollBy(lines);
531
+ }
532
+ }
533
+ if (mouseInput.hasMouse) {
534
+ if (!mouseInput.strippedInput)
535
+ return;
536
+ input = mouseInput.strippedInput;
537
+ }
538
+ const pageScrollDirection = transcriptPageScrollDirection(key, { overlayActive });
539
+ if (pageScrollDirection) {
540
+ viewportRef.current?.scrollPage(pageScrollDirection);
541
+ return;
542
+ }
543
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || statsPanel)
427
544
  return;
428
- if (!pickerMode && key.pageUp) {
429
- viewportRef.current?.scrollPage("up");
545
+ if (key.ctrl && input.toLowerCase() === "p" && !pickerMode && !activeAbortRef.current) {
546
+ setStatsPanel(null);
547
+ setPickerMode("slash");
430
548
  return;
431
549
  }
432
- if (!pickerMode && key.pageDown) {
433
- viewportRef.current?.scrollPage("down");
550
+ if (key.ctrl && key.shift && input.toLowerCase() === "m" && !pickerMode) {
551
+ if (!mcpManager || mcpManager.getStates().length === 0) {
552
+ addMessage("assistant", "No MCP servers configured.");
553
+ }
554
+ else {
555
+ setStatsPanel(null);
556
+ setPickerMode("mcp-reconnect");
557
+ }
434
558
  return;
435
559
  }
436
- if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback)
560
+ if (key.ctrl && input.toLowerCase() === "t" && !pickerMode) {
561
+ setShowThinking((current) => {
562
+ const next = !current;
563
+ addMessage("assistant", next ? "Thinking blocks visible" : "Thinking blocks hidden");
564
+ return next;
565
+ });
437
566
  return;
567
+ }
438
568
  if (key.ctrl && input === "o" && !pickerMode) {
439
569
  setVerboseTrace((v) => !v);
440
570
  return;
@@ -481,6 +611,23 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
481
611
  const addMessage = useCallback((role, content) => {
482
612
  updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
483
613
  }, [updateDisplayMessages]);
614
+ useEffect(() => {
615
+ if (!updateNoticeRefresh)
616
+ return;
617
+ let cancelled = false;
618
+ updateNoticeRefresh.then((notice) => {
619
+ if (cancelled || !notice)
620
+ return;
621
+ setCurrentUpdateNotice(notice);
622
+ if (!showWelcomeRef.current)
623
+ addMessage("assistant", notice);
624
+ }).catch(() => {
625
+ // Best-effort update checks should never disturb the session.
626
+ });
627
+ return () => {
628
+ cancelled = true;
629
+ };
630
+ }, [addMessage, updateNoticeRefresh]);
484
631
  const clearMessages = useCallback(() => {
485
632
  // The transcript lives entirely in React state now (alt-screen viewport,
486
633
  // no terminal scrollback) — clearing state clears the screen. Writing
@@ -494,26 +641,42 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
494
641
  viewportRef.current?.forceScrollToBottom();
495
642
  return key;
496
643
  }, [updateDisplayMessages]);
644
+ const prepareSubmitDisplay = useCallback((payload) => {
645
+ if (payload.images.length === 0)
646
+ return payload;
647
+ if (payload.imageDisplayStart !== undefined) {
648
+ nextImageDisplayLabelStartRef.current = Math.max(nextImageDisplayLabelStartRef.current, payload.imageDisplayStart + payload.images.length);
649
+ return payload;
650
+ }
651
+ const imageDisplayStart = nextImageDisplayLabelStartRef.current;
652
+ nextImageDisplayLabelStartRef.current += payload.images.length;
653
+ return { ...payload, imageDisplayStart };
654
+ }, []);
655
+ const submitDisplayText = useCallback((payload) => (formatImageUserDisplayText(payload.displayText ?? payload.text, payload.images.length, payload.imageDisplayStart)), []);
656
+ const currentSessionFile = useCallback(() => sessionManager?.getSessionFile(), [sessionManager]);
497
657
  const queueInput = useCallback((payload) => {
498
- const displayKey = addStatusUserMessage(payload.displayText ?? payload.text, "queued");
499
- queuedInputsRef.current.push({ payload, displayKey });
658
+ const preparedPayload = prepareSubmitDisplay(payload);
659
+ const displayKey = addStatusUserMessage(submitDisplayText(preparedPayload), "queued");
660
+ queuedInputsRef.current.push({ payload: preparedPayload, displayKey, sessionFile: currentSessionFile() });
500
661
  setQueuedCount(queuedInputsRef.current.length);
501
- }, [addStatusUserMessage]);
662
+ }, [addStatusUserMessage, currentSessionFile, prepareSubmitDisplay, submitDisplayText]);
502
663
  const submitSteer = useCallback((payload) => {
503
664
  const controller = inputControllerRef.current;
504
665
  if (!controller) {
505
666
  queueInput(payload);
506
667
  return;
507
668
  }
508
- const displayKey = addStatusUserMessage(payload.displayText ?? payload.text, "pending_steer");
509
- const pending = controller.enqueue(payload.text);
510
- pendingSteersRef.current.set(pending.id, { displayKey });
669
+ const preparedPayload = prepareSubmitDisplay(payload);
670
+ const displayKey = addStatusUserMessage(submitDisplayText(preparedPayload), "pending_steer");
671
+ const pending = controller.enqueue(preparedPayload.text);
672
+ pendingSteersRef.current.set(pending.id, { displayKey, sessionFile: currentSessionFile() });
511
673
  setPendingSteerCount(pendingSteersRef.current.size);
512
- }, [addStatusUserMessage, queueInput]);
674
+ }, [addStatusUserMessage, currentSessionFile, prepareSubmitDisplay, queueInput, submitDisplayText]);
513
675
  const openPicker = useCallback((mode, providerId) => {
514
676
  if (mode === "key") {
515
677
  setKeyProviderId(providerId ?? null);
516
678
  }
679
+ setStatsPanel(null);
517
680
  setPickerMode(mode);
518
681
  }, []);
519
682
  const closePicker = useCallback(() => {
@@ -529,71 +692,151 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
529
692
  const clearComposerDraft = useCallback(() => {
530
693
  setComposerDraft(null);
531
694
  }, []);
695
+ const setStartingSubmit = useCallback((fingerprint) => {
696
+ startingSubmitFingerprintRef.current = fingerprint;
697
+ setStartingSubmitFingerprint(fingerprint);
698
+ }, []);
532
699
  const openFeedback = useCallback((initialDescription) => {
533
700
  const base = collectFeedback(agent, { description: "" });
534
701
  const { description: _drop, ...rest } = base;
535
702
  setPendingFeedback({ base: rest, initialDescription });
536
703
  }, [agent]);
537
- const handleModelSelect = useCallback((model) => {
704
+ const sidebarFits = terminalColumns > 120;
705
+ const sidebarVisible = sidebarMode === "expanded" ? sidebarFits : sidebarMode === "auto" && sidebarFits;
706
+ const currentSidebarCommandState = useCallback((mode = sidebarMode) => {
707
+ const visible = mode === "expanded" ? sidebarFits : mode === "auto" && sidebarFits;
708
+ return { mode, visible, active: visible };
709
+ }, [sidebarFits, sidebarMode]);
710
+ const toggleSidebar = useCallback(() => {
711
+ const next = sidebarVisible ? "collapsed" : "expanded";
712
+ setSidebarMode(next);
713
+ return currentSidebarCommandState(next);
714
+ }, [currentSidebarCommandState, sidebarVisible]);
715
+ const applySidebarMode = useCallback((mode) => {
716
+ setSidebarMode(mode);
717
+ return currentSidebarCommandState(mode);
718
+ }, [currentSidebarCommandState]);
719
+ const openSessionPicker = useCallback(() => {
720
+ if (activeAbortRef.current) {
721
+ addMessage("error", "Stop the current run before switching sessions.");
722
+ return;
723
+ }
724
+ setStatsPanel(null);
725
+ setPickerMode("session");
726
+ }, [addMessage]);
727
+ const openRewindPicker = useCallback(() => {
728
+ if (!sessionManager) {
729
+ addMessage("error", "Rewind requires an active session.");
730
+ return;
731
+ }
732
+ if (activeAbortRef.current) {
733
+ addMessage("error", "Stop the current run before rewinding.");
734
+ return;
735
+ }
736
+ setStatsPanel(null);
737
+ setPickerMode("rewind");
738
+ }, [addMessage, sessionManager]);
739
+ const openStatsPanel = useCallback(() => {
740
+ setPickerMode(null);
741
+ setStatsPanel({
742
+ range: "30d",
743
+ bundle: collectUsageStatsBundle(),
744
+ });
745
+ }, []);
746
+ const closeStatsPanel = useCallback(() => {
747
+ setStatsPanel(null);
748
+ setCursorResetEpoch((epoch) => epoch + 1);
749
+ }, []);
750
+ const handleSessionSelect = useCallback((sessionFile) => {
751
+ if (!switchSession) {
752
+ addMessage("error", "Session switching is not available in this mode.");
753
+ closePicker();
754
+ return;
755
+ }
756
+ if (activeAbortRef.current) {
757
+ addMessage("error", "Stop the current run before switching sessions.");
758
+ closePicker();
759
+ return;
760
+ }
761
+ const result = switchSession(sessionFile);
762
+ if ("error" in result) {
763
+ addMessage("error", `Failed to switch session: ${result.error}`);
764
+ closePicker();
765
+ return;
766
+ }
767
+ const queuedDisplayKeys = queuedAndPendingDisplayKeys(queuedInputsRef.current, pendingSteersRef.current.values());
768
+ queuedInputsRef.current = [];
769
+ pendingSteersRef.current.clear();
770
+ inputControllerRef.current = null;
771
+ setQueuedCount(0);
772
+ setPendingSteerCount(0);
773
+ setStartingSubmit(null);
774
+ clearComposerDraft();
775
+ setSessionManager(result.manager);
776
+ setTodos(agent.getTodos());
777
+ updateDisplayMessages(() => [
778
+ ...reconstructDisplayMessages(agent.messages).filter((message) => !queuedDisplayKeys.has(message.key ?? "")),
779
+ withMessageKey({ role: "assistant", content: `⤷ Resumed session: ${sessionDisplayName(result.manager)}` }),
780
+ ]);
781
+ viewportRef.current?.forceScrollToBottom();
782
+ closePicker();
783
+ }, [addMessage, agent, clearComposerDraft, closePicker, setStartingSubmit, switchSession, updateDisplayMessages]);
784
+ const handleModelSelect = useCallback((model, selectedThinkingLevel) => {
785
+ const run = async () => {
786
+ const nextThinkingLevel = await switchAgentModel({
787
+ model,
788
+ agent,
789
+ registry: safeRegistry,
790
+ createProvider,
791
+ workingDir: args.cwd,
792
+ systemPromptOptions: agent.getSystemPromptToolOptions(),
793
+ thinkingLevel: selectedThinkingLevel,
794
+ rememberModel: (nextModel) => userConfig.pushRecentModel(nextModel),
795
+ setThinkingLevel,
796
+ sessionManager,
797
+ });
798
+ const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
799
+ ? ` with ${nextThinkingLevel} effort`
800
+ : "";
801
+ addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`);
802
+ closePicker();
803
+ return nextThinkingLevel;
804
+ };
805
+ void run().catch((error) => {
806
+ addMessage("error", formatModelSwitchError(model, error));
807
+ closePicker();
808
+ });
809
+ }, [agent, addMessage, closePicker, sessionManager, userConfig, safeRegistry, createProvider]);
810
+ const handleProviderSelect = useCallback((providerId) => {
538
811
  const run = async () => {
539
- agent.model = model;
540
- const decoded = model.includes(":")
541
- ? model.split(":")
542
- : [agent.providerId || safeRegistry.getDefault()?.id || "openai", model];
543
- const providerId = decoded[0];
544
812
  await safeRegistry.prepareProvider(providerId);
545
- const provider = safeRegistry.getConfigured().find((item) => item.id === providerId);
546
- if (!provider?.apiKey || !createProvider) {
547
- addMessage("error", `Provider ${providerId} is not configured or has no active credentials.`);
813
+ const configured = safeRegistry.getConfigured();
814
+ const p = configured.find((x) => x.id === providerId);
815
+ const builtin = BUILTIN_PROVIDERS.find((x) => x.id === providerId);
816
+ if (!p && !builtin) {
817
+ addMessage("error", `Provider ${providerId} not found.`);
548
818
  closePicker();
549
819
  return;
550
820
  }
551
- const modelId = model.includes(":") ? model.split(":").slice(1).join(":") : model;
552
- agent.thinking = normalizeThinkingLevel(agent.thinking || getDefaultThinkingLevel(providerId, modelId), getAvailableThinkingLevels(providerId, modelId));
553
- agent.setProvider(createProvider(providerId, provider.apiKey, provider.baseURL));
821
+ if (!p?.apiKey) {
822
+ if (!p && builtin) {
823
+ safeRegistry.addProvider(providerId, "");
824
+ }
825
+ safeRegistry.setDefault(providerId);
826
+ setKeyProviderId(providerId);
827
+ setPickerMode("key");
828
+ return;
829
+ }
830
+ safeRegistry.setDefault(providerId);
831
+ agent.setProvider(createProvider(providerId, p.apiKey, p.baseURL));
554
832
  agent.providerId = providerId;
555
- agent.setSystemPrompt(buildSystemPrompt({
556
- agentName: "Bubble",
557
- configuredProvider: providerId,
558
- configuredModel: displayModel(model),
559
- configuredModelId: model,
560
- thinkingLevel: agent.thinking,
561
- workingDir: args.cwd,
562
- ...agent.getSystemPromptToolOptions(),
563
- }));
564
- userConfig.pushRecentModel(model);
565
- setThinkingLevel(agent.thinking);
566
- sessionManager?.updateMetadata({ model, thinkingLevel: agent.thinking, reasoningEffort: agent.thinking });
567
- sessionManager?.appendMarker("model_switch", model);
568
- addMessage("assistant", `Model switched to ${displayModel(model)}.`);
833
+ addMessage("assistant", `Switched to provider ${p.name}. Use /model to pick a model.`);
569
834
  closePicker();
570
835
  };
571
- void run();
572
- }, [agent, addMessage, closePicker, sessionManager, userConfig, safeRegistry, createProvider]);
573
- const handleProviderSelect = useCallback(async (providerId) => {
574
- await safeRegistry.prepareProvider(providerId);
575
- const configured = safeRegistry.getConfigured();
576
- const p = configured.find((x) => x.id === providerId);
577
- const builtin = BUILTIN_PROVIDERS.find((x) => x.id === providerId);
578
- if (!p && !builtin) {
579
- addMessage("error", `Provider ${providerId} not found.`);
836
+ void run().catch((error) => {
837
+ addMessage("error", `Failed to switch provider ${providerId}: ${errorMessage(error)}`);
580
838
  closePicker();
581
- return;
582
- }
583
- if (!p?.apiKey) {
584
- if (!p && builtin) {
585
- safeRegistry.addProvider(providerId, "");
586
- }
587
- safeRegistry.setDefault(providerId);
588
- setKeyProviderId(providerId);
589
- setPickerMode("key");
590
- return;
591
- }
592
- safeRegistry.setDefault(providerId);
593
- agent.setProvider(createProvider(providerId, p.apiKey, p.baseURL));
594
- agent.providerId = providerId;
595
- addMessage("assistant", `Switched to provider ${p.name}. Use /model to pick a model.`);
596
- closePicker();
839
+ });
597
840
  }, [addMessage, agent, closePicker, createProvider, safeRegistry]);
598
841
  const handleProviderAddSelect = useCallback((providerId) => {
599
842
  const ok = safeRegistry.addProvider(providerId, "");
@@ -620,7 +863,10 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
620
863
  throw new Error("Provider creation not available");
621
864
  }),
622
865
  openPicker,
866
+ openSessionPicker,
867
+ openRewindPicker,
623
868
  openFeedback,
869
+ fillComposer,
624
870
  registry: safeRegistry,
625
871
  skillRegistry: safeSkillRegistry,
626
872
  bashAllowlist,
@@ -635,11 +881,12 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
635
881
  getThemeMode: () => themeMode,
636
882
  getResolvedTheme: () => themeResolved,
637
883
  setThemeMode: applyThemeMode,
884
+ openStats: openStatsPanel,
638
885
  });
639
886
  if (handled && result) {
640
887
  addMessage("assistant", result);
641
888
  }
642
- }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, openPicker, safeRegistry, sessionManager]);
889
+ }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, fillComposer, openPicker, openSessionPicker, openRewindPicker, openStatsPanel, safeRegistry, sessionManager]);
643
890
  const handleLogoutProviderSelect = useCallback(async (providerId) => {
644
891
  closePicker();
645
892
  const command = `/logout ${providerId}`;
@@ -654,7 +901,10 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
654
901
  throw new Error("Provider creation not available");
655
902
  }),
656
903
  openPicker,
904
+ openSessionPicker,
905
+ openRewindPicker,
657
906
  openFeedback,
907
+ fillComposer,
658
908
  registry: safeRegistry,
659
909
  skillRegistry: safeSkillRegistry,
660
910
  bashAllowlist,
@@ -669,11 +919,12 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
669
919
  getThemeMode: () => themeMode,
670
920
  getResolvedTheme: () => themeResolved,
671
921
  setThemeMode: applyThemeMode,
922
+ openStats: openStatsPanel,
672
923
  });
673
924
  if (handled && result) {
674
925
  addMessage("assistant", result);
675
926
  }
676
- }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, openPicker, safeRegistry, sessionManager]);
927
+ }, [agent, addMessage, clearMessages, closePicker, createProvider, exit, fillComposer, openPicker, openSessionPicker, openRewindPicker, openStatsPanel, safeRegistry, sessionManager]);
677
928
  const handleKeySubmit = useCallback((key) => {
678
929
  const targetId = keyProviderId || safeRegistry.getDefault()?.id;
679
930
  if (!targetId) {
@@ -693,16 +944,15 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
693
944
  setKeyProviderId(null);
694
945
  }, [addMessage, agent, closePicker, createProvider, keyProviderId, safeRegistry]);
695
946
  const handleSubmit = useCallback(async (payload) => {
696
- const normalized = typeof payload === "string" ? { text: payload, images: [] } : payload;
697
- const input = normalized.text;
698
- const displayInput = normalized.displayText ?? input;
699
- const images = normalized.images;
947
+ const initialPayload = typeof payload === "string" ? { text: payload, images: [] } : payload;
948
+ const input = initialPayload.text;
949
+ const displayInput = initialPayload.displayText ?? input;
950
+ const images = initialPayload.images;
700
951
  if (!input.trim() && images.length === 0)
701
952
  return;
702
953
  // Agent already running: route the submit into the live run instead of
703
954
  // starting a new one. Plain prose steers the current turn; slash
704
- // commands, @-mentions and image payloads queue for the next turn
705
- // (mirrors the OpenTUI boundary-steer eligibility rules).
955
+ // commands, @-mentions and image payloads queue for the next turn.
706
956
  if (activeAbortRef.current) {
707
957
  if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
708
958
  requestExit();
@@ -712,435 +962,641 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
712
962
  !input.includes("@") &&
713
963
  images.length === 0;
714
964
  if (steerEligible) {
715
- submitSteer(normalized);
965
+ submitSteer(initialPayload);
716
966
  }
717
967
  else {
718
- queueInput(normalized);
968
+ queueInput(initialPayload);
719
969
  }
720
970
  return;
721
971
  }
722
- const runAgentInput = async (actualInput, displayInput, attachedImages = []) => {
723
- const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
724
- const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
725
- if (!hasActiveProvider) {
726
- addMessage("error", "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.");
727
- return;
728
- }
729
- if (!agent.model) {
730
- addMessage("error", "No model selected. Use /model after /login or provider setup.");
731
- return;
732
- }
733
- const displayContent = attachedImages.length > 0
734
- ? `${displayInput}${displayInput ? "\n" : ""}${attachedImages
735
- .map((img, i) => `[image${attachedImages.length > 1 ? ` ${i + 1}` : ""}: ${img.filename ?? "clipboard"} · ${Math.max(1, Math.round(img.bytes / 1024))}KB]`)
736
- .join(" ")}`
737
- : displayInput;
738
- updateDisplayMessages((prev) => [
739
- ...prev,
740
- withMessageKey({ role: "user", content: displayContent }),
741
- ]);
742
- // Sending is an explicit "watch the newest turn" intent: snap the
743
- // transcript back to the bottom even if the user had scrolled up.
744
- viewportRef.current?.forceScrollToBottom();
745
- setIsRunning(true);
746
- runStartRef.current = Date.now();
747
- setStreamingContent("");
748
- setStreamingReasoning("");
749
- setStreamingTools([]);
750
- setStreamingParts([]);
751
- let assistantContent = "";
752
- let assistantReasoning = "";
753
- const toolCalls = [];
754
- const assistantParts = [];
755
- const abortController = new AbortController();
756
- activeAbortRef.current = abortController;
757
- const inputController = new AgentRunInputQueue(`run-${++nextRunIdRef.current}`);
758
- inputControllerRef.current = inputController;
759
- const syncStreamingParts = () => {
760
- setStreamingParts(snapshotDisplayParts(assistantParts));
761
- };
762
- // Text/reasoning deltas arrive far faster than the screen needs to
763
- // update; batch them so the full-frame re-render runs at most every
764
- // STREAMING_FLUSH_INTERVAL_MS. Tool events stay immediate.
765
- let streamingFlushTimer = null;
766
- const cancelStreamingFlush = () => {
767
- if (streamingFlushTimer !== null) {
768
- clearTimeout(streamingFlushTimer);
769
- streamingFlushTimer = null;
770
- }
771
- };
772
- const scheduleStreamingFlush = () => {
773
- if (streamingFlushTimer !== null)
774
- return;
775
- streamingFlushTimer = setTimeout(() => {
776
- streamingFlushTimer = null;
777
- setStreamingContent(assistantContent);
778
- setStreamingReasoning(assistantReasoning);
779
- syncStreamingParts();
780
- }, STREAMING_FLUSH_INTERVAL_MS);
781
- };
782
- const hasAssistantOutput = () => (!!assistantContent ||
783
- !!assistantReasoning ||
784
- toolCalls.length > 0 ||
785
- assistantParts.length > 0);
786
- const commitAssistantMessage = (taskElapsedMs) => {
787
- if (!hasAssistantOutput())
972
+ const submitFingerprint = submitPayloadFingerprint(initialPayload);
973
+ const startingDecision = decideStartingSubmitFingerprint(startingSubmitFingerprintRef.current, submitFingerprint);
974
+ if (startingDecision === "ignore")
975
+ return;
976
+ if (startingDecision === "queue") {
977
+ queueInput(initialPayload);
978
+ return;
979
+ }
980
+ const normalized = prepareSubmitDisplay(initialPayload);
981
+ setStartingSubmit(submitFingerprint);
982
+ try {
983
+ const runAgentInput = async (actualInput, displayInput, attachedImages = [], runOptions = {}) => {
984
+ const runSessionFile = currentSessionFile();
985
+ const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
986
+ const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
987
+ if (!hasActiveProvider) {
988
+ addMessage("error", "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.");
989
+ if (runOptions.goalRun && goalStore?.snapshot()?.status === "active") {
990
+ goalStore.pause();
991
+ addMessage("assistant", stopReasonNotice("error"));
992
+ }
788
993
  return;
789
- const currentParts = snapshotDisplayParts(assistantParts);
790
- const currentToolCalls = [...toolCalls];
791
- const partContent = assistantContent || contentFromParts(currentParts);
792
- const partToolCalls = currentToolCalls.length > 0
793
- ? currentToolCalls
794
- : toolCallsFromParts(currentParts);
795
- const msg = {
796
- key: nextDisplayMessageKey("asst"),
797
- role: "assistant",
798
- content: partContent,
799
- };
800
- if (assistantReasoning) {
801
- msg.reasoning = assistantReasoning;
802
994
  }
803
- if (partToolCalls.length > 0) {
804
- msg.toolCalls = partToolCalls;
805
- }
806
- if (currentParts.length > 0) {
807
- msg.parts = currentParts;
995
+ if (!agent.model) {
996
+ addMessage("error", "No model selected. Use /model after /login or provider setup.");
997
+ if (runOptions.goalRun && goalStore?.snapshot()?.status === "active") {
998
+ goalStore.pause();
999
+ addMessage("assistant", stopReasonNotice("error"));
1000
+ }
1001
+ return;
808
1002
  }
809
- if (taskElapsedMs !== undefined && Number.isFinite(taskElapsedMs) && taskElapsedMs > 0) {
810
- msg.taskElapsedMs = taskElapsedMs;
1003
+ const displayContent = formatImageUserDisplayText(displayInput, attachedImages.length, runOptions.imageDisplayStart);
1004
+ if (!runOptions.hidden) {
1005
+ updateDisplayMessages((prev) => [
1006
+ ...prev,
1007
+ withMessageKey({ role: "user", content: displayContent }),
1008
+ ]);
1009
+ // Sending is an explicit "watch the newest turn" intent: snap the
1010
+ // transcript back to the bottom even if the user had scrolled up.
1011
+ viewportRef.current?.forceScrollToBottom();
811
1012
  }
812
- updateDisplayMessages((prev) => [...prev, msg]);
813
- };
814
- const clearAssistantStream = () => {
815
- // A timer firing after this reset would resurrect the just-committed
816
- // text as a phantom streaming block — cancel before clearing.
817
- cancelStreamingFlush();
1013
+ setIsRunning(true);
1014
+ runStartRef.current = Date.now();
818
1015
  setStreamingContent("");
819
1016
  setStreamingReasoning("");
820
1017
  setStreamingTools([]);
821
1018
  setStreamingParts([]);
822
- assistantContent = "";
823
- assistantReasoning = "";
824
- toolCalls.length = 0;
825
- assistantParts.length = 0;
826
- };
827
- try {
828
- for await (const event of agent.run(actualInput, args.cwd, {
829
- abortSignal: abortController.signal,
830
- inputController,
831
- })) {
832
- switch (event.type) {
833
- case "text_delta":
834
- assistantContent += event.content;
835
- appendTextPart(assistantParts, event.content);
836
- scheduleStreamingFlush();
837
- break;
838
- case "reasoning_delta":
839
- assistantReasoning += event.content;
840
- scheduleStreamingFlush();
841
- break;
842
- case "tool_call_start": {
843
- // The LLM has begun emitting this tool call. Args are still
844
- // streaming — render an empty-args placeholder so the user
845
- // sees the tool the moment it appears in the assistant
846
- // response, not after the full arg payload finishes.
847
- if (!toolCalls.some((t) => t.id === event.id)) {
848
- const toolCall = {
849
- id: event.id,
850
- name: event.name,
851
- args: {},
852
- startedAt: Date.now(),
853
- };
854
- toolCalls.push(toolCall);
855
- appendToolPart(assistantParts, toolCall);
856
- setStreamingTools([...toolCalls]);
857
- syncStreamingParts();
1019
+ let assistantContent = "";
1020
+ let assistantReasoning = "";
1021
+ let goalRunTokens = 0;
1022
+ let goalRunUsageReported = false;
1023
+ let runCancelled = false;
1024
+ let runErrored = false;
1025
+ const toolCalls = [];
1026
+ const assistantParts = [];
1027
+ const abortController = new AbortController();
1028
+ activeAbortRef.current = abortController;
1029
+ setStartingSubmit(null);
1030
+ const inputController = new AgentRunInputQueue(`run-${++nextRunIdRef.current}`);
1031
+ inputControllerRef.current = inputController;
1032
+ const syncStreamingParts = () => {
1033
+ setStreamingParts(snapshotDisplayParts(assistantParts));
1034
+ };
1035
+ // Text/reasoning deltas arrive far faster than the screen needs to
1036
+ // update; batch them so the full-frame re-render runs at most every
1037
+ // STREAMING_FLUSH_INTERVAL_MS. Tool events stay immediate.
1038
+ let streamingFlushTimer = null;
1039
+ const cancelStreamingFlush = () => {
1040
+ if (streamingFlushTimer !== null) {
1041
+ clearTimeout(streamingFlushTimer);
1042
+ streamingFlushTimer = null;
1043
+ }
1044
+ };
1045
+ const scheduleStreamingFlush = () => {
1046
+ if (streamingFlushTimer !== null)
1047
+ return;
1048
+ streamingFlushTimer = setTimeout(() => {
1049
+ streamingFlushTimer = null;
1050
+ setStreamingContent(assistantContent);
1051
+ setStreamingReasoning(assistantReasoning);
1052
+ syncStreamingParts();
1053
+ }, STREAMING_FLUSH_INTERVAL_MS);
1054
+ };
1055
+ const hasAssistantOutput = () => (!!assistantContent ||
1056
+ !!assistantReasoning ||
1057
+ toolCalls.length > 0 ||
1058
+ assistantParts.length > 0);
1059
+ const commitAssistantMessage = (taskElapsedMs) => {
1060
+ if (!hasAssistantOutput())
1061
+ return;
1062
+ const currentParts = snapshotDisplayParts(assistantParts);
1063
+ const currentToolCalls = [...toolCalls];
1064
+ const partContent = assistantContent || contentFromParts(currentParts);
1065
+ const partToolCalls = currentToolCalls.length > 0
1066
+ ? currentToolCalls
1067
+ : toolCallsFromParts(currentParts);
1068
+ const msg = {
1069
+ key: nextDisplayMessageKey("asst"),
1070
+ role: "assistant",
1071
+ content: partContent,
1072
+ };
1073
+ if (assistantReasoning) {
1074
+ msg.reasoning = assistantReasoning;
1075
+ }
1076
+ if (partToolCalls.length > 0) {
1077
+ msg.toolCalls = partToolCalls;
1078
+ }
1079
+ if (currentParts.length > 0) {
1080
+ msg.parts = currentParts;
1081
+ }
1082
+ if (taskElapsedMs !== undefined && Number.isFinite(taskElapsedMs) && taskElapsedMs > 0) {
1083
+ msg.taskElapsedMs = taskElapsedMs;
1084
+ }
1085
+ updateDisplayMessages((prev) => [...prev, msg]);
1086
+ };
1087
+ const clearAssistantStream = () => {
1088
+ // A timer firing after this reset would resurrect the just-committed
1089
+ // text as a phantom streaming block — cancel before clearing.
1090
+ cancelStreamingFlush();
1091
+ setStreamingContent("");
1092
+ setStreamingReasoning("");
1093
+ setStreamingTools([]);
1094
+ setStreamingParts([]);
1095
+ assistantContent = "";
1096
+ assistantReasoning = "";
1097
+ toolCalls.length = 0;
1098
+ assistantParts.length = 0;
1099
+ };
1100
+ try {
1101
+ for await (const event of agent.run(actualInput, args.cwd, {
1102
+ abortSignal: abortController.signal,
1103
+ inputController,
1104
+ })) {
1105
+ switch (event.type) {
1106
+ case "text_delta":
1107
+ assistantContent += event.content;
1108
+ appendTextPart(assistantParts, event.content);
1109
+ scheduleStreamingFlush();
1110
+ break;
1111
+ case "reasoning_delta":
1112
+ assistantReasoning += event.content;
1113
+ scheduleStreamingFlush();
1114
+ break;
1115
+ case "tool_call_start": {
1116
+ // The LLM has begun emitting this tool call. Args are still
1117
+ // streaming — render an empty-args placeholder so the user
1118
+ // sees the tool the moment it appears in the assistant
1119
+ // response, not after the full arg payload finishes.
1120
+ if (!toolCalls.some((t) => t.id === event.id)) {
1121
+ const toolCall = {
1122
+ id: event.id,
1123
+ name: event.name,
1124
+ args: {},
1125
+ startedAt: Date.now(),
1126
+ };
1127
+ toolCalls.push(toolCall);
1128
+ appendToolPart(assistantParts, toolCall);
1129
+ setStreamingTools([...toolCalls]);
1130
+ syncStreamingParts();
1131
+ }
1132
+ break;
858
1133
  }
859
- break;
860
- }
861
- case "tool_call_delta": {
862
- // Best-effort parse of the partial argument JSON to extract
863
- // identifying fields (path, command, content, …). The buffer
864
- // is incomplete JSON during streaming, so fall back to regex
865
- // peeks on common string fields.
866
- const tc = toolCalls.find((t) => t.id === event.id);
867
- if (tc) {
868
- tc.args = parsePartialArgs(event.arguments, tc.args);
1134
+ case "tool_call_delta": {
1135
+ // Best-effort parse of the partial argument JSON to extract
1136
+ // identifying fields (path, command, content, …). The buffer
1137
+ // is incomplete JSON during streaming, so fall back to regex
1138
+ // peeks on common string fields.
1139
+ const tc = toolCalls.find((t) => t.id === event.id);
1140
+ if (tc) {
1141
+ tc.args = parsePartialArgs(event.arguments, tc.args);
1142
+ setStreamingTools([...toolCalls]);
1143
+ syncStreamingParts();
1144
+ }
1145
+ break;
1146
+ }
1147
+ case "tool_call_end": {
1148
+ // Provider signaled args streaming is complete; agent will
1149
+ // emit tool_start next. We don't need to do anything visual
1150
+ // here — the placeholder is already in place and tool_start
1151
+ // will refresh it with the canonical parsed args.
1152
+ break;
1153
+ }
1154
+ case "tool_start": {
1155
+ // Tool is about to execute. Upgrade the placeholder created
1156
+ // by tool_call_start (or append if upstream skipped the
1157
+ // streaming path).
1158
+ const existing = toolCalls.find((t) => t.id === event.id);
1159
+ if (existing) {
1160
+ existing.args = event.args;
1161
+ existing.startedAt = existing.startedAt ?? Date.now();
1162
+ }
1163
+ else {
1164
+ const toolCall = {
1165
+ id: event.id,
1166
+ name: event.name,
1167
+ args: event.args,
1168
+ startedAt: Date.now(),
1169
+ };
1170
+ toolCalls.push(toolCall);
1171
+ appendToolPart(assistantParts, toolCall);
1172
+ }
869
1173
  setStreamingTools([...toolCalls]);
870
1174
  syncStreamingParts();
1175
+ break;
871
1176
  }
872
- break;
873
- }
874
- case "tool_call_end": {
875
- // Provider signaled args streaming is complete; agent will
876
- // emit tool_start next. We don't need to do anything visual
877
- // here — the placeholder is already in place and tool_start
878
- // will refresh it with the canonical parsed args.
879
- break;
880
- }
881
- case "tool_start": {
882
- // Tool is about to execute. Upgrade the placeholder created
883
- // by tool_call_start (or append if upstream skipped the
884
- // streaming path).
885
- const existing = toolCalls.find((t) => t.id === event.id);
886
- if (existing) {
887
- existing.args = event.args;
888
- existing.startedAt = existing.startedAt ?? Date.now();
1177
+ case "tool_end": {
1178
+ const tc = toolCalls.find((t) => t.id === event.id);
1179
+ if (tc) {
1180
+ tc.result = event.result.content;
1181
+ tc.isError = event.result.isError;
1182
+ tc.metadata = event.result.metadata;
1183
+ setStreamingTools([...toolCalls]);
1184
+ syncStreamingParts();
1185
+ }
1186
+ break;
889
1187
  }
890
- else {
891
- const toolCall = {
892
- id: event.id,
893
- name: event.name,
894
- args: event.args,
895
- startedAt: Date.now(),
896
- };
897
- toolCalls.push(toolCall);
898
- appendToolPart(assistantParts, toolCall);
1188
+ case "tool_update": {
1189
+ const tc = toolCalls.find((t) => t.id === event.id);
1190
+ if (tc) {
1191
+ tc.metadata = mergeToolMetadata(tc.metadata, event.update.metadata);
1192
+ if (event.update.message) {
1193
+ tc.result = event.update.message;
1194
+ }
1195
+ tc.isError = event.update.status === "failed"
1196
+ || event.update.status === "blocked"
1197
+ || event.update.status === "cancelled";
1198
+ setStreamingTools([...toolCalls]);
1199
+ syncStreamingParts();
1200
+ }
1201
+ break;
899
1202
  }
900
- setStreamingTools([...toolCalls]);
901
- syncStreamingParts();
902
- break;
903
- }
904
- case "tool_end": {
905
- const tc = toolCalls.find((t) => t.id === event.id);
906
- if (tc) {
907
- tc.result = event.result.content;
908
- tc.isError = event.result.isError;
909
- tc.metadata = event.result.metadata;
910
- setStreamingTools([...toolCalls]);
911
- syncStreamingParts();
1203
+ case "todos_updated": {
1204
+ setTodos(event.todos);
1205
+ break;
912
1206
  }
913
- break;
914
- }
915
- case "tool_update": {
916
- const tc = toolCalls.find((t) => t.id === event.id);
917
- if (tc) {
918
- tc.metadata = mergeToolMetadata(tc.metadata, event.update.metadata);
919
- if (event.update.message) {
920
- tc.result = event.update.message;
1207
+ case "mode_changed": {
1208
+ setPermissionMode(event.mode);
1209
+ sessionManager?.appendMarker("mode_switch", event.mode);
1210
+ break;
1211
+ }
1212
+ case "input_applied": {
1213
+ // The steer joined the current turn at the next model-call
1214
+ // boundary. Move it after the just-finished tool/assistant
1215
+ // turn instead of clearing the badge in its original
1216
+ // placeholder position.
1217
+ const steer = pendingSteersRef.current.get(event.id);
1218
+ if (steer) {
1219
+ pendingSteersRef.current.delete(event.id);
1220
+ setPendingSteerCount(pendingSteersRef.current.size);
1221
+ updateDisplayMessages((prev) => moveStatusMessageToEnd(prev, steer.displayKey));
921
1222
  }
922
- tc.isError = event.update.status === "failed"
923
- || event.update.status === "blocked"
924
- || event.update.status === "cancelled";
925
- setStreamingTools([...toolCalls]);
926
- syncStreamingParts();
1223
+ break;
927
1224
  }
928
- break;
929
- }
930
- case "todos_updated": {
931
- setTodos(event.todos);
932
- break;
933
- }
934
- case "mode_changed": {
935
- setPermissionMode(event.mode);
936
- sessionManager?.appendMarker("mode_switch", event.mode);
937
- break;
938
- }
939
- case "input_applied": {
940
- // The steer joined the current turn — its placeholder row
941
- // becomes a regular user message (badge cleared).
942
- const steer = pendingSteersRef.current.get(event.id);
943
- if (steer) {
944
- pendingSteersRef.current.delete(event.id);
945
- setPendingSteerCount(pendingSteersRef.current.size);
946
- updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message) : message));
1225
+ case "input_rejected": {
1226
+ // No model continuation left in this run: the steer moves to
1227
+ // the next turn's queue, badge flips to QUEUED.
1228
+ const steer = pendingSteersRef.current.get(event.id);
1229
+ if (steer) {
1230
+ pendingSteersRef.current.delete(event.id);
1231
+ setPendingSteerCount(pendingSteersRef.current.size);
1232
+ updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
1233
+ queuedInputsRef.current.push({
1234
+ payload: { text: event.content, images: [] },
1235
+ displayKey: steer.displayKey,
1236
+ sessionFile: steer.sessionFile ?? runSessionFile,
1237
+ });
1238
+ setQueuedCount(queuedInputsRef.current.length);
1239
+ }
1240
+ break;
947
1241
  }
948
- break;
949
- }
950
- case "input_rejected": {
951
- // No model continuation left in this run: the steer moves to
952
- // the next turn's queue, badge flips to QUEUED.
953
- const steer = pendingSteersRef.current.get(event.id);
954
- if (steer) {
955
- pendingSteersRef.current.delete(event.id);
956
- setPendingSteerCount(pendingSteersRef.current.size);
957
- updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
958
- queuedInputsRef.current.push({
959
- payload: { text: event.content, images: [] },
960
- displayKey: steer.displayKey,
961
- });
962
- setQueuedCount(queuedInputsRef.current.length);
1242
+ case "input_pending_changed": {
1243
+ if (event.pending === 0 && pendingSteersRef.current.size > 0) {
1244
+ pendingSteersRef.current.clear();
1245
+ }
1246
+ setPendingSteerCount(event.pending === 0 ? 0 : event.pending);
1247
+ break;
963
1248
  }
964
- break;
965
- }
966
- case "input_pending_changed": {
967
- if (event.pending === 0 && pendingSteersRef.current.size > 0) {
968
- pendingSteersRef.current.clear();
1249
+ case "turn_end": {
1250
+ if (event.usage) {
1251
+ goalRunUsageReported = true;
1252
+ goalRunTokens += tokenUsageTotal(event.usage);
1253
+ }
1254
+ if (event.willContinue) {
1255
+ commitAssistantMessage();
1256
+ clearAssistantStream();
1257
+ break;
1258
+ }
1259
+ commitAssistantMessage(runStartRef.current ? Date.now() - runStartRef.current : undefined);
1260
+ clearAssistantStream();
1261
+ break;
969
1262
  }
970
- setPendingSteerCount(event.pending === 0 ? 0 : event.pending);
971
- break;
972
1263
  }
973
- case "turn_end": {
974
- if (event.willContinue) {
975
- syncStreamingParts();
976
- break;
1264
+ }
1265
+ }
1266
+ catch (err) {
1267
+ commitAssistantMessage();
1268
+ if (err instanceof AgentAbortError || err?.name === "AbortError") {
1269
+ runCancelled = true;
1270
+ updateDisplayMessages(() => reconstructDisplayMessages(agent.messages));
1271
+ }
1272
+ else {
1273
+ runErrored = true;
1274
+ updateDisplayMessages((prev) => [
1275
+ ...prev,
1276
+ withMessageKey({ role: "error", content: err.message }),
1277
+ ]);
1278
+ }
1279
+ }
1280
+ finally {
1281
+ cancelStreamingFlush();
1282
+ // Leftover steers that never reached a model-call boundary: drop
1283
+ // them on cancel (the user asked the run to stop); requeue them for
1284
+ // the next turn on a normal end.
1285
+ const cancelled = abortController.signal.aborted;
1286
+ if (cancelled)
1287
+ runCancelled = true;
1288
+ for (const leftover of inputController.clear()) {
1289
+ const steer = pendingSteersRef.current.get(leftover.id);
1290
+ pendingSteersRef.current.delete(leftover.id);
1291
+ if (cancelled) {
1292
+ if (steer) {
1293
+ updateDisplayMessages((prev) => prev.filter((message) => message.key !== steer.displayKey));
977
1294
  }
978
- commitAssistantMessage(runStartRef.current ? Date.now() - runStartRef.current : undefined);
979
- clearAssistantStream();
980
- break;
1295
+ continue;
1296
+ }
1297
+ if (steer) {
1298
+ updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
981
1299
  }
1300
+ queuedInputsRef.current.push({
1301
+ payload: { text: leftover.content, images: [] },
1302
+ displayKey: steer?.displayKey,
1303
+ sessionFile: steer?.sessionFile ?? runSessionFile,
1304
+ });
982
1305
  }
1306
+ setPendingSteerCount(0);
1307
+ setQueuedCount(queuedInputsRef.current.length);
1308
+ if (inputControllerRef.current === inputController)
1309
+ inputControllerRef.current = null;
1310
+ if (activeAbortRef.current === abortController)
1311
+ activeAbortRef.current = null;
1312
+ setIsRunning(false);
1313
+ runStartRef.current = null;
1314
+ setStreamingContent("");
1315
+ setStreamingReasoning("");
1316
+ setStreamingTools([]);
1317
+ setStreamingParts([]);
1318
+ maybeContinueGoal({
1319
+ runCancelled,
1320
+ runErrored,
1321
+ isGoalRun: !!runOptions.goalRun,
1322
+ runTokens: goalRunTokens,
1323
+ usageReported: goalRunUsageReported,
1324
+ });
983
1325
  }
984
- }
985
- catch (err) {
986
- commitAssistantMessage();
987
- if (err instanceof AgentAbortError || err?.name === "AbortError") {
988
- updateDisplayMessages(() => reconstructDisplayMessages(agent.messages));
1326
+ };
1327
+ const kickGoalTurn = (prompt, visibleInput) => {
1328
+ if (activeAbortRef.current)
1329
+ return;
1330
+ queueMicrotask(() => {
1331
+ void runAgentInput(prompt, visibleInput ?? "", [], {
1332
+ hidden: visibleInput === undefined,
1333
+ goalRun: true,
1334
+ });
1335
+ });
1336
+ };
1337
+ function maybeContinueGoal(input) {
1338
+ if (!goalStore || exitRequestedRef.current)
1339
+ return;
1340
+ const current = goalStore.snapshot();
1341
+ if (!current)
1342
+ return;
1343
+ if (input.runCancelled || input.runErrored) {
1344
+ if (current.status === "active") {
1345
+ goalStore.pause();
1346
+ addMessage("assistant", stopReasonNotice(input.runErrored ? "error" : "cancelled"));
1347
+ }
1348
+ return;
989
1349
  }
990
- else {
991
- updateDisplayMessages((prev) => [
992
- ...prev,
993
- withMessageKey({ role: "error", content: err.message }),
994
- ]);
1350
+ if (input.isGoalRun) {
1351
+ if (input.usageReported) {
1352
+ if (input.runTokens > 0)
1353
+ goalStore.addTokens(input.runTokens);
1354
+ }
1355
+ else {
1356
+ goalStore.markTokenUsageUnavailable();
1357
+ }
1358
+ goalStore.incrementTurn();
1359
+ }
1360
+ const goal = goalStore.snapshot();
1361
+ const decision = shouldContinueGoal({
1362
+ goal,
1363
+ queuedInputs: queuedInputsRef.current.length,
1364
+ });
1365
+ if (decision.continue) {
1366
+ kickGoalTurn(formatInternalContextBlock("goal", continuationPrompt(goal)));
1367
+ return;
995
1368
  }
1369
+ if (decision.reason === "budget" && goal.status === "active") {
1370
+ goalStore.markBudgetLimited();
1371
+ }
1372
+ if (decision.reason === "complete") {
1373
+ addMessage("assistant", goalCompleteNotice(goal));
1374
+ return;
1375
+ }
1376
+ const note = stopReasonNotice(decision.reason);
1377
+ if (note)
1378
+ addMessage("assistant", note);
996
1379
  }
997
- finally {
998
- cancelStreamingFlush();
999
- // Leftover steers that never reached a model-call boundary: drop
1000
- // them on cancel (the user asked the run to stop); requeue them for
1001
- // the next turn on a normal end (mirrors the OpenTUI run teardown).
1002
- const cancelled = abortController.signal.aborted;
1003
- for (const leftover of inputController.clear()) {
1004
- const steer = pendingSteersRef.current.get(leftover.id);
1005
- pendingSteersRef.current.delete(leftover.id);
1006
- if (cancelled) {
1007
- if (steer) {
1008
- updateDisplayMessages((prev) => prev.filter((message) => message.key !== steer.displayKey));
1380
+ const handleGoalCommand = async (goalInput) => {
1381
+ if (!goalStore) {
1382
+ addMessage("error", "Goals are not available in this session.");
1383
+ return;
1384
+ }
1385
+ const command = parseGoalCommand(goalInput);
1386
+ if (command.error) {
1387
+ addMessage("error", command.error);
1388
+ return;
1389
+ }
1390
+ const existing = goalStore.snapshot();
1391
+ switch (command.kind) {
1392
+ case "show": {
1393
+ addMessage("assistant", existing ? goalSummaryText(existing) : "No active goal. Set one with /goal <objective>");
1394
+ return;
1395
+ }
1396
+ case "clear": {
1397
+ if (!existing) {
1398
+ addMessage("assistant", "No active goal to clear");
1399
+ return;
1009
1400
  }
1010
- continue;
1401
+ goalStore.clear();
1402
+ addMessage("assistant", "Goal cleared");
1403
+ return;
1404
+ }
1405
+ case "pause": {
1406
+ if (!existing) {
1407
+ addMessage("assistant", "No active goal to pause");
1408
+ return;
1409
+ }
1410
+ goalStore.pause();
1411
+ addMessage("assistant", "Goal paused — /goal resume to continue");
1412
+ return;
1413
+ }
1414
+ case "resume": {
1415
+ if (!existing) {
1416
+ addMessage("assistant", "No goal to resume. Set one with /goal <objective>");
1417
+ return;
1418
+ }
1419
+ const resumed = goalStore.resume();
1420
+ if (resumed?.status === "active") {
1421
+ addMessage("assistant", "Goal resumed");
1422
+ kickGoalTurn(formatInternalContextBlock("goal", continuationPrompt(resumed)));
1423
+ }
1424
+ else {
1425
+ addMessage("assistant", "Goal cannot be resumed (already complete)");
1426
+ }
1427
+ return;
1428
+ }
1429
+ case "edit": {
1430
+ if (!existing) {
1431
+ addMessage("assistant", "No active goal to edit. Set one with /goal <objective>");
1432
+ return;
1433
+ }
1434
+ goalStore.edit(command.objective);
1435
+ if (command.tokenBudget !== undefined)
1436
+ goalStore.setBudget(command.tokenBudget);
1437
+ addMessage("assistant", `Goal updated: ${truncate(goalStore.snapshot().objective, 60)}`);
1438
+ return;
1011
1439
  }
1012
- if (steer) {
1013
- updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
1440
+ case "set": {
1441
+ const goal = goalStore.set(command.objective, { tokenBudget: command.tokenBudget });
1442
+ const budgetNote = goal.tokenBudget !== undefined ? ` (budget ${goal.tokenBudget} tok)` : "";
1443
+ addMessage("assistant", `Goal set${budgetNote} — working autonomously. /goal pause to stop.`);
1444
+ kickGoalTurn(formatInternalContextBlock("goal", initialPrompt(goal)), goalInput.trim());
1445
+ return;
1014
1446
  }
1015
- queuedInputsRef.current.push({
1016
- payload: { text: leftover.content, images: [] },
1017
- displayKey: steer?.displayKey,
1447
+ }
1448
+ };
1449
+ // Slash commands and skill invocations drop any attached images —
1450
+ // they're meant for pure command routing.
1451
+ if (displayInput.startsWith("/")) {
1452
+ // Fast-path `/quit` and `/exit` before slash-registry / skill
1453
+ // resolution. This guarantees a literal "/quit" always exits even if
1454
+ // a skill or alias of the same name is later registered. The
1455
+ // canonical handler still lives in slash-commands/commands.ts so
1456
+ // `/help` and the slash menu can list it; both paths end up calling
1457
+ // requestExit().
1458
+ if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
1459
+ requestExit();
1460
+ return;
1461
+ }
1462
+ if (/^\/(?:thinking|toggle-thinking)(?:\s|$)/.test(input.trim())) {
1463
+ setShowThinking((current) => {
1464
+ const next = !current;
1465
+ addMessage("assistant", next ? "Thinking blocks visible" : "Thinking blocks hidden");
1466
+ return next;
1018
1467
  });
1468
+ return;
1019
1469
  }
1020
- setPendingSteerCount(0);
1021
- setQueuedCount(queuedInputsRef.current.length);
1022
- if (inputControllerRef.current === inputController)
1023
- inputControllerRef.current = null;
1024
- if (activeAbortRef.current === abortController)
1025
- activeAbortRef.current = null;
1026
- setIsRunning(false);
1027
- runStartRef.current = null;
1028
- setStreamingContent("");
1029
- setStreamingReasoning("");
1030
- setStreamingTools([]);
1031
- setStreamingParts([]);
1032
- }
1033
- };
1034
- // Slash commands and skill invocations drop any attached images —
1035
- // they're meant for pure command routing.
1036
- if (displayInput.startsWith("/")) {
1037
- // Fast-path `/quit` and `/exit` before slash-registry / skill
1038
- // resolution. This guarantees a literal "/quit" always exits even if
1039
- // a skill or alias of the same name is later registered. The
1040
- // canonical handler still lives in slash-commands/commands.ts so
1041
- // `/help` and the slash menu can list it; both paths end up calling
1042
- // requestExit().
1043
- if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
1044
- requestExit();
1045
- return;
1046
- }
1047
- const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
1048
- if (skillInvocation) {
1049
- await runAgentInput(skillInvocation.actualPrompt, displayInput);
1050
- return;
1051
- }
1052
- const { handled, result, inject } = await slashRegistry.execute(input, {
1053
- agent,
1054
- addMessage,
1055
- clearMessages,
1056
- cwd: args.cwd,
1057
- exit: () => { requestExit(); },
1058
- sessionManager,
1059
- createProvider: createProvider ?? (() => {
1060
- throw new Error("Provider creation not available");
1061
- }),
1062
- openPicker,
1063
- openFeedback,
1064
- fillComposer,
1065
- registry: safeRegistry,
1066
- skillRegistry: safeSkillRegistry,
1067
- bashAllowlist,
1068
- settingsManager,
1069
- lspService,
1070
- mcpManager,
1071
- hookController,
1072
- flushMemory,
1073
- runMemoryCompaction,
1074
- runMemorySummary,
1075
- runMemoryRefresh,
1076
- getThemeMode: () => themeMode,
1077
- getResolvedTheme: () => themeResolved,
1078
- setThemeMode: applyThemeMode,
1079
- });
1080
- if (handled) {
1081
- if (agent.mode !== permissionMode) {
1082
- setPermissionMode(agent.mode);
1470
+ if (/^\/(?:trace|verbose|debug)(?:\s|$)/.test(input.trim())) {
1471
+ setVerboseTrace((current) => {
1472
+ const next = !current;
1473
+ addMessage("assistant", next ? "Verbose trace visible" : "Compact trace visible");
1474
+ return next;
1475
+ });
1476
+ return;
1083
1477
  }
1084
- if (result) {
1085
- // `/compact` rewrites agent.messages, so the Ink transcript needs to
1086
- // be rebuilt from the new agent state before appending the summary
1087
- // card; otherwise the pre-compaction history would keep rendering.
1088
- if (result.startsWith("✓ Compaction complete")) {
1089
- const summary = latestCompactionSummary(agent.messages);
1090
- updateDisplayMessages(() => [
1091
- ...reconstructDisplayMessages(agent.messages),
1092
- {
1093
- role: "assistant",
1094
- content: result,
1095
- syntheticKind: "ui_compact_summary",
1096
- compactionSummary: summary,
1097
- },
1098
- ]);
1478
+ if (/^\/write-previews(?:\s|$)/.test(input.trim())) {
1479
+ setExpandedToolOutput((current) => {
1480
+ const next = !current;
1481
+ addMessage("assistant", next ? "Write previews expanded" : "Write previews collapsed");
1482
+ return next;
1483
+ });
1484
+ return;
1485
+ }
1486
+ if (/^\/goal(?:\s|$)/.test(input.trim())) {
1487
+ await handleGoalCommand(input);
1488
+ return;
1489
+ }
1490
+ const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
1491
+ if (skillInvocation) {
1492
+ await runAgentInput(skillInvocation.actualPrompt, displayInput);
1493
+ return;
1494
+ }
1495
+ const { handled, result, inject } = await slashRegistry.execute(input, {
1496
+ agent,
1497
+ addMessage,
1498
+ clearMessages,
1499
+ cwd: args.cwd,
1500
+ exit: () => { requestExit(); },
1501
+ sessionManager,
1502
+ createProvider: createProvider ?? (() => {
1503
+ throw new Error("Provider creation not available");
1504
+ }),
1505
+ openPicker,
1506
+ openSessionPicker,
1507
+ openRewindPicker,
1508
+ openFeedback,
1509
+ fillComposer,
1510
+ registry: safeRegistry,
1511
+ skillRegistry: safeSkillRegistry,
1512
+ bashAllowlist,
1513
+ settingsManager,
1514
+ lspService,
1515
+ mcpManager,
1516
+ hookController,
1517
+ flushMemory,
1518
+ runMemoryCompaction,
1519
+ runMemorySummary,
1520
+ runMemoryRefresh,
1521
+ getThemeMode: () => themeMode,
1522
+ getResolvedTheme: () => themeResolved,
1523
+ setThemeMode: applyThemeMode,
1524
+ toggleSidebar,
1525
+ setSidebarMode: applySidebarMode,
1526
+ openStats: openStatsPanel,
1527
+ });
1528
+ if (handled) {
1529
+ if (agent.mode !== permissionMode) {
1530
+ setPermissionMode(agent.mode);
1099
1531
  }
1100
- else if (result.startsWith("⏪")) {
1101
- // /rewind truncated agent.messages rebuild the transcript from
1102
- // the rewound state before appending the summary.
1103
- updateDisplayMessages(() => [
1104
- ...reconstructDisplayMessages(agent.messages),
1105
- { role: "assistant", content: result },
1106
- ]);
1532
+ if (result) {
1533
+ // `/compact` rewrites agent.messages, so the Ink transcript needs to
1534
+ // be rebuilt from the new agent state before appending the summary
1535
+ // card; otherwise the pre-compaction history would keep rendering.
1536
+ if (result.startsWith("✓ Compaction complete")) {
1537
+ const summary = latestCompactionSummary(agent.messages);
1538
+ updateDisplayMessages(() => [
1539
+ ...reconstructDisplayMessages(agent.messages),
1540
+ {
1541
+ role: "assistant",
1542
+ content: result,
1543
+ syntheticKind: "ui_compact_summary",
1544
+ compactionSummary: summary,
1545
+ },
1546
+ ]);
1547
+ }
1548
+ else if (result.startsWith("⏪")) {
1549
+ // /rewind truncated agent.messages — rebuild the transcript from
1550
+ // the rewound state before appending the summary.
1551
+ updateDisplayMessages(() => [
1552
+ ...reconstructDisplayMessages(agent.messages),
1553
+ { role: "assistant", content: result },
1554
+ ]);
1555
+ }
1556
+ else {
1557
+ addMessage("assistant", result);
1558
+ }
1107
1559
  }
1108
- else {
1109
- addMessage("assistant", result);
1560
+ if (inject) {
1561
+ await runAgentInput(inject, displayInput);
1110
1562
  }
1563
+ return;
1111
1564
  }
1112
- if (inject) {
1113
- await runAgentInput(inject, displayInput);
1114
- }
1115
- return;
1565
+ }
1566
+ const expansion = await expandAtMentions(input, args.cwd);
1567
+ if (expansion.missing.length > 0) {
1568
+ addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
1569
+ }
1570
+ for (const skip of expansion.skipped) {
1571
+ if (skip.reason !== "too large")
1572
+ addMessage("error", `Skipped @${skip.path}: ${skip.reason}`);
1573
+ }
1574
+ const agentInput = images.length > 0
1575
+ ? [
1576
+ ...(expansion.text ? [{ type: "text", text: expansion.text }] : []),
1577
+ ...images.map((img) => ({
1578
+ type: "image_url",
1579
+ image_url: { url: img.dataUrl },
1580
+ })),
1581
+ ]
1582
+ : expansion.text;
1583
+ await runAgentInput(agentInput, displayInput, images.map((img) => ({ filename: img.filename, bytes: img.bytes })), { imageDisplayStart: normalized.imageDisplayStart });
1584
+ }
1585
+ finally {
1586
+ if (startingSubmitFingerprintRef.current === submitFingerprint) {
1587
+ setStartingSubmit(null);
1116
1588
  }
1117
1589
  }
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
- if (skip.reason !== "too large")
1124
- addMessage("error", `Skipped @${skip.path}: ${skip.reason}`);
1125
- }
1126
- const agentInput = images.length > 0
1127
- ? [
1128
- ...(expansion.text ? [{ type: "text", text: expansion.text }] : []),
1129
- ...images.map((img) => ({
1130
- type: "image_url",
1131
- image_url: { url: img.dataUrl },
1132
- })),
1133
- ]
1134
- : expansion.text;
1135
- await runAgentInput(agentInput, displayInput, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
1136
- }, [addMessage, agent, args.cwd, openPicker, createProvider, fillComposer, safeRegistry, safeSkillRegistry, updateDisplayMessages, queueInput, submitSteer, requestExit]);
1590
+ }, [addMessage, agent, args.cwd, openPicker, openSessionPicker, openRewindPicker, openStatsPanel, createProvider, currentSessionFile, fillComposer, prepareSubmitDisplay, safeRegistry, safeSkillRegistry, updateDisplayMessages, queueInput, submitSteer, requestExit, toggleSidebar, applySidebarMode, setStartingSubmit]);
1137
1591
  // Drain the queue once the run ends and no modal needs the user first.
1138
1592
  // The placeholder row is removed right before resubmitting — handleSubmit
1139
1593
  // renders the message again as a regular user row.
1140
1594
  const drainQueuedInput = useCallback(() => {
1141
1595
  if (activeAbortRef.current)
1142
1596
  return;
1143
- if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode)
1597
+ if (startingSubmitFingerprintRef.current)
1598
+ return;
1599
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode || statsPanel)
1144
1600
  return;
1145
1601
  const next = queuedInputsRef.current.shift();
1146
1602
  if (!next)
@@ -1149,16 +1605,20 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1149
1605
  if (next.displayKey) {
1150
1606
  updateDisplayMessages((prev) => prev.filter((message) => message.key !== next.displayKey));
1151
1607
  }
1608
+ if (!isQueuedInputForCurrentSession(next, currentSessionFile()))
1609
+ return;
1152
1610
  void handleSubmit(next.payload);
1153
- }, [pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, updateDisplayMessages, handleSubmit]);
1611
+ }, [pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, statsPanel, currentSessionFile, updateDisplayMessages, handleSubmit]);
1154
1612
  useEffect(() => {
1155
1613
  if (isRunning || queuedCount === 0)
1156
1614
  return;
1157
- if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode)
1615
+ if (startingSubmitFingerprint)
1616
+ return;
1617
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode || statsPanel)
1158
1618
  return;
1159
1619
  const timer = setTimeout(drainQueuedInput, 0);
1160
1620
  return () => clearTimeout(timer);
1161
- }, [isRunning, queuedCount, pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, drainQueuedInput]);
1621
+ }, [isRunning, queuedCount, startingSubmitFingerprint, pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, statsPanel, drainQueuedInput]);
1162
1622
  const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
1163
1623
  const keyTarget = keyProviderId
1164
1624
  ? safeRegistry.getConfigured().find((p) => p.id === keyProviderId)
@@ -1181,7 +1641,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1181
1641
  const showThinkingLabel = getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2
1182
1642
  && thinkingLevel
1183
1643
  && thinkingLevel !== "off";
1184
- const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: updateNotice, cwd: friendlyCwd(args.cwd), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
1644
+ const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
1645
+ const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry), [safeSkillRegistry]);
1646
+ const mcpReconnectItems = useMemo(() => buildMcpReconnectItems(mcpManager), [mcpManager]);
1185
1647
  // One row shorter than the terminal on purpose. A frame that exactly fills
1186
1648
  // the screen makes Ink omit the trailing newline ("fullscreen" mode), and
1187
1649
  // Ink's cursor-only repositioning (buildReturnToBottom) miscalculates by one
@@ -1190,64 +1652,440 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1190
1652
  // stdout write). Keeping every frame below viewport height keeps all of
1191
1653
  // Ink's cursor paths on the consistent trailing-newline math.
1192
1654
  const frameRows = Math.max(4, terminalRows - 1);
1193
- return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", width: terminalColumns, height: frameRows, children: [_jsx(TranscriptViewport, { ref: viewportRef, children: _jsx(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: _jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }) }) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1194
- .filter((p) => isUserVisibleProvider(p.id))
1195
- .map((p) => {
1196
- const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1197
- const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1198
- return {
1199
- id: p.id,
1200
- name: `${p.name} [${configuredLabel}]`,
1201
- enabled: true,
1202
- };
1203
- }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker }) })), pickerMode === "provider-add" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1204
- .filter((p) => isUserVisibleProvider(p.id))
1205
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" }) })), pickerMode === "login" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1206
- .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1207
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" }) })), pickerMode === "logout" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1208
- .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1209
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" }) })), pickerMode === "key" && keyTarget && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1210
- closePicker();
1211
- setKeyProviderId(null);
1212
- } }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1213
- fillComposer(`/${name} `);
1214
- closePicker();
1215
- }, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
1216
- closePicker();
1217
- addMessage("assistant", summary);
1218
- }, onCancel: () => {
1219
- closePicker();
1220
- addMessage("assistant", "已取消 Feishu setup。");
1221
- } }) })), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1222
- const resolve = pendingPlan.resolve;
1223
- setPendingPlan(null);
1224
- resolve({ action: "approve", plan: finalPlan });
1225
- }, onReject: (reason) => {
1226
- const resolve = pendingPlan.resolve;
1227
- setPendingPlan(null);
1228
- resolve({ action: "reject", reason });
1229
- } }) })), pendingApproval && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
1230
- const resolve = pendingApproval.resolve;
1231
- setPendingApproval(null);
1232
- resolve(decision);
1233
- }, onAllowBashPrefix: (prefix) => {
1234
- bashAllowlist?.add(prefix);
1235
- } }) })), pendingQuestion && !pickerMode && !pendingPlan && !pendingApproval && !pendingFeedback && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
1236
- questionController?.reply(pendingQuestion.id, answers);
1237
- setPendingQuestion(null);
1238
- }, onCancel: () => {
1239
- questionController?.reject(pendingQuestion.id);
1240
- setPendingQuestion(null);
1241
- } }) })), pendingFeedback && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeedbackDialog, { base: pendingFeedback.base, initialDescription: pendingFeedback.initialDescription, onDismiss: () => setPendingFeedback(null), onResult: (result) => {
1242
- if (result.kind === "success") {
1243
- addMessage("assistant", `Feedback submitted: ${result.url}`);
1244
- }
1245
- else if (result.kind === "error") {
1246
- addMessage("error", `Feedback failed: ${result.message}`);
1247
- }
1248
- } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 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, pendingSteerCount: pendingSteerCount, queuedCount: queuedCount }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, onWheelScroll: (direction, lines) => {
1249
- viewportRef.current?.scrollBy(direction === "up" ? -lines : lines);
1250
- }, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({ mode: permissionMode }) }) }))] }) }));
1655
+ const sidebarWidth = sidebarVisible ? Math.min(42, Math.max(28, Math.floor(terminalColumns * 0.34))) : 0;
1656
+ const mainWidth = Math.max(40, terminalColumns - sidebarWidth);
1657
+ return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "row", width: terminalColumns, height: frameRows, backgroundColor: palette.background, children: [_jsxs(Box, { flexDirection: "column", width: mainWidth, height: frameRows, backgroundColor: palette.background, children: [_jsx(TranscriptViewport, { ref: viewportRef, children: _jsx(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: _jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: mainWidth, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }) }) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, currentThinkingLevel: thinkingLevel, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1658
+ .filter((p) => isUserVisibleProvider(p.id))
1659
+ .map((p) => {
1660
+ const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1661
+ const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1662
+ return {
1663
+ id: p.id,
1664
+ name: `${p.name} [${configuredLabel}]`,
1665
+ enabled: true,
1666
+ };
1667
+ }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker }) })), pickerMode === "provider-add" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1668
+ .filter((p) => isUserVisibleProvider(p.id))
1669
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" }) })), pickerMode === "login" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1670
+ .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1671
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" }) })), pickerMode === "logout" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1672
+ .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1673
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" }) })), pickerMode === "key" && keyTarget && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1674
+ closePicker();
1675
+ setKeyProviderId(null);
1676
+ } }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1677
+ fillComposer(`/${name} `);
1678
+ closePicker();
1679
+ }, onCancel: closePicker }) })), pickerMode === "slash" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(CommandPalette, { items: commandPaletteItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1680
+ closePicker();
1681
+ if (item.action === "insert-skill") {
1682
+ fillComposer(`/${item.value} `);
1683
+ }
1684
+ else {
1685
+ void handleSubmit(item.command);
1686
+ }
1687
+ }, onCancel: closePicker }) })), pickerMode === "mcp-reconnect" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(McpReconnectPicker, { items: mcpReconnectItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1688
+ closePicker();
1689
+ void handleSubmit(item.command);
1690
+ }, onCancel: closePicker }) })), pickerMode === "session" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SessionPicker, { currentCwd: args.cwd, currentSessions: SessionManager.summarizeSessionsForCwd(args.cwd), allSessions: SessionManager.listAllSessions(), onSelect: handleSessionSelect, onCancel: closePicker }) })), pickerMode === "rewind" && sessionManager && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(RewindPicker, { sessionManager: sessionManager, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (command) => {
1691
+ closePicker();
1692
+ void handleSubmit(command);
1693
+ }, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
1694
+ closePicker();
1695
+ addMessage("assistant", summary);
1696
+ }, onCancel: () => {
1697
+ closePicker();
1698
+ addMessage("assistant", "已取消 Feishu setup。");
1699
+ } }) })), statsPanel && !pickerMode && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(StatsPanel, { panel: statsPanel, terminalColumns: mainWidth, terminalRows: terminalRows, onRangeChange: (range) => setStatsPanel((current) => current ? { ...current, range } : current), onCancel: closeStatsPanel }) })), todos.length > 0 && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !statsPanel && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1700
+ const resolve = pendingPlan.resolve;
1701
+ setPendingPlan(null);
1702
+ resolve({ action: "approve", plan: finalPlan });
1703
+ }, onReject: (reason) => {
1704
+ const resolve = pendingPlan.resolve;
1705
+ setPendingPlan(null);
1706
+ resolve({ action: "reject", reason });
1707
+ } }) })), pendingApproval && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
1708
+ const resolve = pendingApproval.resolve;
1709
+ setPendingApproval(null);
1710
+ resolve(decision);
1711
+ }, onAllowBashPrefix: (prefix) => {
1712
+ bashAllowlist?.add(prefix);
1713
+ } }) })), pendingQuestion && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingFeedback && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
1714
+ questionController?.reply(pendingQuestion.id, answers);
1715
+ setPendingQuestion(null);
1716
+ }, onCancel: () => {
1717
+ questionController?.reject(pendingQuestion.id);
1718
+ setPendingQuestion(null);
1719
+ } }) })), pendingFeedback && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeedbackDialog, { base: pendingFeedback.base, initialDescription: pendingFeedback.initialDescription, onDismiss: () => setPendingFeedback(null), onResult: (result) => {
1720
+ if (result.kind === "success") {
1721
+ addMessage("assistant", `Feedback submitted: ${result.url}`);
1722
+ }
1723
+ else if (result.kind === "error") {
1724
+ addMessage("error", `Feedback failed: ${result.message}`);
1725
+ }
1726
+ } }) })), !isExiting && isRunning && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick, pendingSteerCount: pendingSteerCount, queuedCount: queuedCount }) })), !isExiting && !pickerMode && !statsPanel && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback || !!statsPanel, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, localSlashCommands: [...INK_LOCAL_SLASH_COMMANDS], terminalColumns: mainWidth, cwd: args.cwd, sessionFile: currentSessionFile(), nextImageLabelStart: nextImageDisplayLabelStartRef.current }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({ mode: permissionMode, goalLine }) }) }))] }), sidebarVisible && (_jsx(InkSidebar, { width: sidebarWidth, agent: agent, sessionManager: sessionManager, cwd: args.cwd, mode: permissionMode, goalLine: goalLine, todos: todos, mcpManager: mcpManager, lspService: lspService }))] }) }));
1727
+ }
1728
+ function buildCommandPaletteItems(skillRegistry) {
1729
+ const items = new Map();
1730
+ const add = (item) => {
1731
+ const key = `${item.action ?? "command"}:${item.value}`;
1732
+ if (!items.has(key))
1733
+ items.set(key, item);
1734
+ };
1735
+ for (const command of INK_LOCAL_SLASH_COMMANDS) {
1736
+ add({
1737
+ label: `/${command.name}`,
1738
+ detail: command.description,
1739
+ value: command.name,
1740
+ command: `/${command.name}`,
1741
+ });
1742
+ }
1743
+ for (const command of slashRegistry.list()) {
1744
+ const source = command.source === "mcp" ? " :mcp" : "";
1745
+ const sourceLabel = command.sourceLabel ? `[${command.sourceLabel}] ` : "";
1746
+ add({
1747
+ label: `/${command.name}${source}`,
1748
+ detail: `${sourceLabel}${command.description}`,
1749
+ value: command.name,
1750
+ command: `/${command.name}`,
1751
+ });
1752
+ }
1753
+ for (const skill of skillRegistry.summaries()) {
1754
+ add({
1755
+ label: `/${skill.name} :skill`,
1756
+ detail: `[${skill.source}] ${skill.description}`,
1757
+ value: skill.name,
1758
+ command: `/${skill.name}`,
1759
+ action: "insert-skill",
1760
+ });
1761
+ }
1762
+ return [...items.values()];
1763
+ }
1764
+ function buildMcpReconnectItems(mcpManager) {
1765
+ return (mcpManager?.getStates() ?? []).map((state) => {
1766
+ let detail;
1767
+ if (state.status.kind === "connected") {
1768
+ const tools = state.status.tools.length;
1769
+ const prompts = state.status.prompts.length;
1770
+ detail = `connected · ${tools} tool${tools === 1 ? "" : "s"} · ${prompts} prompt${prompts === 1 ? "" : "s"}`;
1771
+ }
1772
+ else if (state.status.kind === "failed") {
1773
+ detail = `failed · ${state.status.error}`;
1774
+ }
1775
+ else {
1776
+ detail = "disabled";
1777
+ }
1778
+ return {
1779
+ label: state.name,
1780
+ detail,
1781
+ value: state.name,
1782
+ command: `/mcp reconnect ${state.name}`,
1783
+ };
1784
+ });
1785
+ }
1786
+ function CommandPalette({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
1787
+ 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 }));
1788
+ }
1789
+ function McpReconnectPicker({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
1790
+ 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 }));
1791
+ }
1792
+ function PalettePicker({ title, hint, emptyText, items, terminalColumns, terminalRows, searchable = false, reconnectAlias = false, onSelect, onCancel, }) {
1793
+ const theme = useTheme();
1794
+ const [query, setQuery] = useState("");
1795
+ const [selectedIndex, setSelectedIndex] = useState(0);
1796
+ const maxVisible = Math.max(5, Math.min(12, terminalRows - 10));
1797
+ const filtered = useMemo(() => {
1798
+ const needle = query.trim().toLowerCase();
1799
+ if (!needle)
1800
+ return items;
1801
+ return items.filter((item) => item.label.toLowerCase().includes(needle) ||
1802
+ item.detail.toLowerCase().includes(needle) ||
1803
+ item.value.toLowerCase().includes(needle));
1804
+ }, [items, query]);
1805
+ useEffect(() => {
1806
+ setSelectedIndex((current) => Math.min(Math.max(0, filtered.length - 1), current));
1807
+ }, [filtered.length]);
1808
+ useInput((input, key) => {
1809
+ if (isKeyReleaseEvent(key))
1810
+ return;
1811
+ if (key.escape) {
1812
+ onCancel();
1813
+ return;
1814
+ }
1815
+ if (key.return || (reconnectAlias && input.toLowerCase() === "r")) {
1816
+ const item = filtered[selectedIndex];
1817
+ if (item)
1818
+ onSelect(item);
1819
+ return;
1820
+ }
1821
+ if (key.upArrow) {
1822
+ setSelectedIndex((index) => Math.max(0, index - 1));
1823
+ return;
1824
+ }
1825
+ if (key.downArrow) {
1826
+ setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + 1));
1827
+ return;
1828
+ }
1829
+ if (key.pageUp) {
1830
+ setSelectedIndex((index) => Math.max(0, index - maxVisible));
1831
+ return;
1832
+ }
1833
+ if (key.pageDown) {
1834
+ setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + maxVisible));
1835
+ return;
1836
+ }
1837
+ if (!searchable)
1838
+ return;
1839
+ if (key.backspace || key.delete) {
1840
+ setQuery((current) => current.slice(0, -1));
1841
+ return;
1842
+ }
1843
+ if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
1844
+ setQuery((current) => current + input);
1845
+ }
1846
+ });
1847
+ const start = clampWindowStartForIndex(filtered.length, selectedIndex, maxVisible);
1848
+ const visible = filtered.slice(start, start + maxVisible);
1849
+ const labelWidth = Math.max(18, Math.min(36, Math.floor(terminalColumns * 0.32)));
1850
+ const detailWidth = Math.max(20, terminalColumns - labelWidth - 10);
1851
+ 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) => {
1852
+ const actualIndex = start + offset;
1853
+ const selected = actualIndex === selectedIndex;
1854
+ 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}`));
1855
+ })] })] }));
1856
+ }
1857
+ const REWIND_SCOPE_ORDER = ["all", "chat", "code"];
1858
+ const REWIND_SCOPE_LABEL = {
1859
+ all: "chat + files",
1860
+ chat: "chat only",
1861
+ code: "files only",
1862
+ };
1863
+ function rewindCommand(turnIndex, scope) {
1864
+ const base = `/rewind ${turnIndex + 1}`;
1865
+ if (scope === "chat")
1866
+ return `${base} --chat`;
1867
+ if (scope === "code")
1868
+ return `${base} --code`;
1869
+ return base;
1870
+ }
1871
+ function cycleRewindScope(scope, direction) {
1872
+ const index = REWIND_SCOPE_ORDER.indexOf(scope);
1873
+ return REWIND_SCOPE_ORDER[(index + direction + REWIND_SCOPE_ORDER.length) % REWIND_SCOPE_ORDER.length];
1874
+ }
1875
+ function RewindPicker({ sessionManager, terminalColumns, terminalRows, onSelect, onCancel, }) {
1876
+ const theme = useTheme();
1877
+ const turns = useMemo(() => sessionManager.listUserTurns(), [sessionManager]);
1878
+ const checkpoints = useMemo(() => sessionManager.getCheckpoints(), [sessionManager]);
1879
+ const fileCounts = useMemo(() => {
1880
+ const entries = checkpoints.listEntries();
1881
+ const byTurn = new Map();
1882
+ for (const entry of entries) {
1883
+ const files = byTurn.get(entry.turn);
1884
+ if (files)
1885
+ files.add(entry.path);
1886
+ else
1887
+ byTurn.set(entry.turn, new Set([entry.path]));
1888
+ }
1889
+ return new Map(turns.map((turn) => [turn.id, byTurn.get(turn.id)?.size ?? 0]));
1890
+ }, [checkpoints, turns]);
1891
+ const [selectedIndex, setSelectedIndex] = useState(() => Math.max(0, turns.length - 1));
1892
+ const [scope, setScope] = useState("all");
1893
+ const maxVisible = Math.max(4, Math.min(10, terminalRows - 10));
1894
+ useEffect(() => {
1895
+ setSelectedIndex((current) => Math.min(Math.max(0, turns.length - 1), current));
1896
+ }, [turns.length]);
1897
+ useInput((input, key) => {
1898
+ if (isKeyReleaseEvent(key))
1899
+ return;
1900
+ if (key.escape) {
1901
+ onCancel();
1902
+ return;
1903
+ }
1904
+ if (key.return) {
1905
+ if (turns[selectedIndex])
1906
+ onSelect(rewindCommand(selectedIndex, scope));
1907
+ return;
1908
+ }
1909
+ if (key.upArrow) {
1910
+ setSelectedIndex((index) => Math.max(0, index - 1));
1911
+ return;
1912
+ }
1913
+ if (key.downArrow) {
1914
+ setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + 1));
1915
+ return;
1916
+ }
1917
+ if (key.pageUp) {
1918
+ setSelectedIndex((index) => Math.max(0, index - maxVisible));
1919
+ return;
1920
+ }
1921
+ if (key.pageDown) {
1922
+ setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + maxVisible));
1923
+ return;
1924
+ }
1925
+ if (key.tab || key.rightArrow || input === "l") {
1926
+ setScope((current) => cycleRewindScope(current, 1));
1927
+ return;
1928
+ }
1929
+ if (key.leftArrow || input === "h") {
1930
+ setScope((current) => cycleRewindScope(current, -1));
1931
+ }
1932
+ });
1933
+ const start = clampWindowStartForIndex(turns.length, selectedIndex, maxVisible);
1934
+ const visibleTurns = turns.slice(start, start + maxVisible);
1935
+ const previewWidth = Math.max(18, Math.min(76, terminalColumns - 34));
1936
+ 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) => {
1937
+ const actualIndex = start + offset;
1938
+ const isSelected = actualIndex === selectedIndex;
1939
+ const touched = fileCounts.get(turn.id) ?? 0;
1940
+ return (_jsx(RewindRow, { turn: turn, turnNumber: actualIndex + 1, selected: isSelected, fileCount: touched, previewWidth: previewWidth }, turn.id));
1941
+ })] })] }));
1942
+ }
1943
+ function RewindRow({ turn, turnNumber, selected, fileCount, previewWidth, }) {
1944
+ const theme = useTheme();
1945
+ const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1946
+ const fileNote = fileCount > 0 ? ` · ${fileCount} file${fileCount === 1 ? "" : "s"}` : "";
1947
+ 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 })] }));
1948
+ }
1949
+ function clampWindowStartForIndex(total, selectedIndex, maxVisible) {
1950
+ if (total <= maxVisible)
1951
+ return 0;
1952
+ const half = Math.floor(maxVisible / 2);
1953
+ let start = Math.max(0, selectedIndex - half);
1954
+ if (start + maxVisible > total)
1955
+ start = total - maxVisible;
1956
+ return Math.max(0, start);
1957
+ }
1958
+ function StatsPanel({ panel, terminalColumns, terminalRows, onRangeChange, onCancel, }) {
1959
+ const theme = useTheme();
1960
+ const [scroll, setScroll] = useState(0);
1961
+ const bodyWidth = Math.max(48, Math.min(92, terminalColumns - 6));
1962
+ const lines = useMemo(() => formatStatsPanelBody(panel.bundle.ranges[panel.range], bodyWidth).split("\n"), [bodyWidth, panel.bundle, panel.range]);
1963
+ const maxVisible = Math.max(5, Math.min(16, terminalRows - 10));
1964
+ const maxScroll = Math.max(0, lines.length - maxVisible);
1965
+ useEffect(() => {
1966
+ setScroll(0);
1967
+ }, [panel.range]);
1968
+ useEffect(() => {
1969
+ setScroll((current) => Math.min(current, maxScroll));
1970
+ }, [maxScroll]);
1971
+ useInput((input, key) => {
1972
+ if (isKeyReleaseEvent(key))
1973
+ return;
1974
+ if (key.escape) {
1975
+ onCancel();
1976
+ return;
1977
+ }
1978
+ if (key.tab) {
1979
+ onRangeChange(panel.range === "30d" ? "7d" : "30d");
1980
+ return;
1981
+ }
1982
+ if (key.leftArrow || input === "h") {
1983
+ onRangeChange("7d");
1984
+ return;
1985
+ }
1986
+ if (key.rightArrow || input === "l") {
1987
+ onRangeChange("30d");
1988
+ return;
1989
+ }
1990
+ if (key.upArrow) {
1991
+ setScroll((current) => Math.max(0, current - 1));
1992
+ return;
1993
+ }
1994
+ if (key.downArrow) {
1995
+ setScroll((current) => Math.min(maxScroll, current + 1));
1996
+ return;
1997
+ }
1998
+ if (key.pageUp) {
1999
+ setScroll((current) => Math.max(0, current - maxVisible));
2000
+ return;
2001
+ }
2002
+ if (key.pageDown) {
2003
+ setScroll((current) => Math.min(maxScroll, current + maxVisible));
2004
+ }
2005
+ });
2006
+ const visible = lines.slice(scroll, scroll + maxVisible);
2007
+ const generatedAt = panel.bundle.generatedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2008
+ 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) => {
2009
+ const key = `${scroll + index}-${line}`;
2010
+ const heading = line === "Activity" || line === "Model usage" || line === "Summary";
2011
+ return (_jsx(Text, { color: heading ? theme.accent : undefined, bold: heading, children: line || " " }, key));
2012
+ }) }), maxScroll > 0 && (_jsxs(Text, { color: theme.muted, children: [scroll + 1, "-", Math.min(lines.length, scroll + maxVisible), " of ", lines.length] }))] }));
2013
+ }
2014
+ function summarizeMcpStates(states) {
2015
+ const summary = { connected: 0, starting: 0, failed: 0, disabled: 0, tools: 0 };
2016
+ for (const state of states) {
2017
+ if (state.status.kind === "connected") {
2018
+ summary.connected += 1;
2019
+ summary.tools += state.status.tools.length;
2020
+ }
2021
+ else if (state.status.kind === "failed") {
2022
+ summary.failed += 1;
2023
+ }
2024
+ else {
2025
+ summary.disabled += 1;
2026
+ }
2027
+ }
2028
+ return summary;
2029
+ }
2030
+ function summarizeLspStatuses(statuses) {
2031
+ const summary = { connected: 0, starting: 0, failed: 0, disabled: 0 };
2032
+ for (const status of statuses) {
2033
+ if (status.status === "connected")
2034
+ summary.connected += 1;
2035
+ else if (status.status === "starting")
2036
+ summary.starting += 1;
2037
+ else
2038
+ summary.failed += 1;
2039
+ }
2040
+ return summary;
2041
+ }
2042
+ function formatStatusCount(summary) {
2043
+ const parts = [];
2044
+ if (summary.connected > 0)
2045
+ parts.push(`${summary.connected} up`);
2046
+ if (summary.starting > 0)
2047
+ parts.push(`${summary.starting} starting`);
2048
+ if (summary.failed > 0)
2049
+ parts.push(`${summary.failed} failed`);
2050
+ if (summary.disabled > 0)
2051
+ parts.push(`${summary.disabled} disabled`);
2052
+ return parts.join(" · ") || "none";
2053
+ }
2054
+ function SidebarSection({ title, children }) {
2055
+ const theme = useTheme();
2056
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), children] }));
2057
+ }
2058
+ function SidebarRow({ label, value, color, }) {
2059
+ const theme = useTheme();
2060
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.muted, children: [label, ": "] }), _jsx(Text, { color: color ?? theme.userMessageText, children: value })] }));
2061
+ }
2062
+ function InkSidebar({ width, agent, sessionManager, cwd, mode, goalLine, todos, mcpManager, lspService, }) {
2063
+ const theme = useTheme();
2064
+ const innerWidth = Math.max(12, width - 4);
2065
+ const todoCounts = todos.reduce((acc, todo) => {
2066
+ acc[todo.status] = (acc[todo.status] ?? 0) + 1;
2067
+ return acc;
2068
+ }, {});
2069
+ const todoSummary = todos.length === 0
2070
+ ? "none"
2071
+ : [
2072
+ todoCounts.in_progress ? `${todoCounts.in_progress} active` : "",
2073
+ todoCounts.pending ? `${todoCounts.pending} pending` : "",
2074
+ todoCounts.completed ? `${todoCounts.completed} done` : "",
2075
+ ].filter(Boolean).join(" · ");
2076
+ const mcpStates = mcpManager?.getStates() ?? [];
2077
+ const mcpSummary = summarizeMcpStates(mcpStates);
2078
+ const lspSummary = lspService?.isDisabled()
2079
+ ? { connected: 0, starting: 0, failed: 0, disabled: 1 }
2080
+ : summarizeLspStatuses(lspService?.status() ?? []);
2081
+ const latestMcpFailure = mcpStates.find((state) => state.status.kind === "failed");
2082
+ const latestLspFailure = lspService?.status().find((status) => status.status === "error");
2083
+ const sessionTitle = truncate(sessionDisplayName(sessionManager), innerWidth);
2084
+ const modelLabel = agent.model ? displayModel(agent.model) : "not selected";
2085
+ const route = agent.providerId
2086
+ ? `${agent.providerId}/${modelLabel}`
2087
+ : modelLabel;
2088
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: "100%", borderStyle: "single", borderColor: theme.border, paddingX: 1, paddingY: 1, flexShrink: 0, children: [_jsx(Text, { color: theme.borderActive, bold: true, children: "Session" }), _jsx(Text, { color: theme.userMessageText, children: sessionTitle }), _jsx(Text, { color: theme.muted, children: truncate(friendlyCwd(cwd), innerWidth) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(SidebarSection, { title: "Runtime", children: [_jsx(SidebarRow, { label: "model", value: truncate(route, innerWidth - 7) }), _jsx(SidebarRow, { label: "mode", value: mode, color: mode === "bypassPermissions" ? theme.warning : theme.userMessageText }), _jsx(SidebarRow, { label: "thinking", value: agent.thinking || "off" })] }), goalLine && (_jsx(SidebarSection, { title: "Goal", children: _jsx(Text, { color: theme.userMessageText, children: truncate(goalLine, innerWidth) }) })), _jsx(SidebarSection, { title: "Todos", children: _jsx(Text, { color: todos.length > 0 ? theme.userMessageText : theme.muted, children: truncate(todoSummary, innerWidth) }) }), _jsxs(SidebarSection, { title: "MCP", children: [_jsx(Text, { color: mcpSummary.failed > 0 ? theme.warning : theme.userMessageText, children: truncate(`${formatStatusCount(mcpSummary)}${mcpSummary.tools > 0 ? ` · ${mcpSummary.tools} tools` : ""}`, innerWidth) }), latestMcpFailure?.status.kind === "failed" && (_jsx(Text, { color: theme.muted, children: truncate(latestMcpFailure.status.error, innerWidth) }))] }), _jsxs(SidebarSection, { title: "LSP", children: [_jsx(Text, { color: lspSummary.failed > 0 ? theme.warning : theme.userMessageText, children: truncate(formatStatusCount(lspSummary), innerWidth) }), latestLspFailure?.message && (_jsx(Text, { color: theme.muted, children: truncate(latestLspFailure.message, innerWidth) }))] })] })] }));
1251
2089
  }
1252
2090
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1253
2091
  const GENERIC_PHRASES = [