@bubblebrain-ai/bubble 0.0.24 → 0.0.26

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