@butlerw/vellum 0.1.0

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 (446) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +411 -0
  3. package/__fixtures__/responses/code-generation.json +42 -0
  4. package/__fixtures__/responses/error-response.json +20 -0
  5. package/__fixtures__/responses/hello-world.json +32 -0
  6. package/dist/auth-6MCXESOH.js +26 -0
  7. package/dist/chunk-SECXJGWA.js +597 -0
  8. package/dist/index.js +34023 -0
  9. package/package.json +67 -0
  10. package/src/__tests__/commands.e2e.test.ts +728 -0
  11. package/src/__tests__/credentials.test.ts +713 -0
  12. package/src/__tests__/mode-e2e.test.ts +391 -0
  13. package/src/__tests__/tui-integration.test.tsx +1271 -0
  14. package/src/agents/__tests__/task-persistence.test.ts +235 -0
  15. package/src/agents/commands/delegate.ts +240 -0
  16. package/src/agents/commands/index.ts +10 -0
  17. package/src/agents/commands/resume.ts +335 -0
  18. package/src/agents/index.ts +29 -0
  19. package/src/agents/task-persistence.ts +272 -0
  20. package/src/agents/task-resumption.ts +242 -0
  21. package/src/app.tsx +4737 -0
  22. package/src/commands/__tests__/.gitkeep +1 -0
  23. package/src/commands/__tests__/agents.test.ts +606 -0
  24. package/src/commands/__tests__/auth.test.ts +626 -0
  25. package/src/commands/__tests__/autocomplete.test.ts +683 -0
  26. package/src/commands/__tests__/batch.test.ts +287 -0
  27. package/src/commands/__tests__/chain-pipe-parser.test.ts +654 -0
  28. package/src/commands/__tests__/completion.test.ts +238 -0
  29. package/src/commands/__tests__/core.test.ts +363 -0
  30. package/src/commands/__tests__/executor.test.ts +496 -0
  31. package/src/commands/__tests__/exit-codes.test.ts +220 -0
  32. package/src/commands/__tests__/init.test.ts +243 -0
  33. package/src/commands/__tests__/language.test.ts +353 -0
  34. package/src/commands/__tests__/mode-cli.test.ts +667 -0
  35. package/src/commands/__tests__/model.test.ts +277 -0
  36. package/src/commands/__tests__/parser.test.ts +493 -0
  37. package/src/commands/__tests__/performance.bench.ts +380 -0
  38. package/src/commands/__tests__/registry.test.ts +534 -0
  39. package/src/commands/__tests__/resume.test.ts +449 -0
  40. package/src/commands/__tests__/security.test.ts +845 -0
  41. package/src/commands/__tests__/stream-json.test.ts +372 -0
  42. package/src/commands/__tests__/user-commands.test.ts +597 -0
  43. package/src/commands/adapters.ts +267 -0
  44. package/src/commands/agent.ts +395 -0
  45. package/src/commands/agents/generate.ts +506 -0
  46. package/src/commands/agents/index.ts +272 -0
  47. package/src/commands/agents/show.ts +271 -0
  48. package/src/commands/agents/validate.ts +387 -0
  49. package/src/commands/auth.ts +883 -0
  50. package/src/commands/autocomplete.ts +480 -0
  51. package/src/commands/batch/command.ts +388 -0
  52. package/src/commands/batch/executor.ts +361 -0
  53. package/src/commands/batch/index.ts +12 -0
  54. package/src/commands/commit.ts +235 -0
  55. package/src/commands/completion/index.ts +371 -0
  56. package/src/commands/condense.ts +191 -0
  57. package/src/commands/config.ts +344 -0
  58. package/src/commands/context-provider.ts +173 -0
  59. package/src/commands/copy.ts +329 -0
  60. package/src/commands/core/clear.ts +38 -0
  61. package/src/commands/core/exit.ts +43 -0
  62. package/src/commands/core/help.ts +354 -0
  63. package/src/commands/core/index.ts +15 -0
  64. package/src/commands/cost.ts +179 -0
  65. package/src/commands/credentials.tsx +618 -0
  66. package/src/commands/custom-agents/__tests__/custom-agents.test.ts +709 -0
  67. package/src/commands/custom-agents/create.ts +377 -0
  68. package/src/commands/custom-agents/export.ts +135 -0
  69. package/src/commands/custom-agents/import.ts +199 -0
  70. package/src/commands/custom-agents/index.ts +372 -0
  71. package/src/commands/custom-agents/info.ts +318 -0
  72. package/src/commands/custom-agents/list.ts +267 -0
  73. package/src/commands/custom-agents/validate.ts +388 -0
  74. package/src/commands/diff-mode.ts +241 -0
  75. package/src/commands/env.ts +53 -0
  76. package/src/commands/executor.ts +579 -0
  77. package/src/commands/exit-codes.ts +202 -0
  78. package/src/commands/index.ts +701 -0
  79. package/src/commands/init/index.ts +15 -0
  80. package/src/commands/init/prompts.ts +366 -0
  81. package/src/commands/init/templates/commands-readme.md +80 -0
  82. package/src/commands/init/templates/example-command.md +79 -0
  83. package/src/commands/init/templates/example-skill.md +168 -0
  84. package/src/commands/init/templates/example-workflow.md +101 -0
  85. package/src/commands/init/templates/prompts-readme.md +52 -0
  86. package/src/commands/init/templates/rules-readme.md +63 -0
  87. package/src/commands/init/templates/skills-readme.md +83 -0
  88. package/src/commands/init/templates/workflows-readme.md +94 -0
  89. package/src/commands/init.ts +391 -0
  90. package/src/commands/install.ts +90 -0
  91. package/src/commands/language.ts +191 -0
  92. package/src/commands/loaders/.gitkeep +1 -0
  93. package/src/commands/lsp.ts +199 -0
  94. package/src/commands/markdown-commands.ts +253 -0
  95. package/src/commands/mcp.ts +588 -0
  96. package/src/commands/memory/export.ts +341 -0
  97. package/src/commands/memory/index.ts +148 -0
  98. package/src/commands/memory/list.ts +261 -0
  99. package/src/commands/memory/search.ts +346 -0
  100. package/src/commands/memory/utils.ts +15 -0
  101. package/src/commands/metrics.ts +75 -0
  102. package/src/commands/migrate/index.ts +16 -0
  103. package/src/commands/migrate/prompts.ts +477 -0
  104. package/src/commands/mode.ts +331 -0
  105. package/src/commands/model.ts +298 -0
  106. package/src/commands/onboard.ts +205 -0
  107. package/src/commands/open.ts +169 -0
  108. package/src/commands/output/stream-json.ts +373 -0
  109. package/src/commands/parser/chain-parser.ts +370 -0
  110. package/src/commands/parser/index.ts +29 -0
  111. package/src/commands/parser/pipe-parser.ts +480 -0
  112. package/src/commands/parser.ts +588 -0
  113. package/src/commands/persistence.ts +355 -0
  114. package/src/commands/progress.ts +18 -0
  115. package/src/commands/prompt/index.ts +17 -0
  116. package/src/commands/prompt/validate.ts +621 -0
  117. package/src/commands/prompt-priority.ts +401 -0
  118. package/src/commands/registry.ts +374 -0
  119. package/src/commands/sandbox/index.ts +131 -0
  120. package/src/commands/security/index.ts +21 -0
  121. package/src/commands/security/input-sanitizer.ts +168 -0
  122. package/src/commands/security/permission-checker.ts +456 -0
  123. package/src/commands/security/sensitive-data.ts +350 -0
  124. package/src/commands/session/delete.ts +38 -0
  125. package/src/commands/session/export.ts +39 -0
  126. package/src/commands/session/index.ts +26 -0
  127. package/src/commands/session/list.ts +26 -0
  128. package/src/commands/session/resume.ts +562 -0
  129. package/src/commands/session/search.ts +434 -0
  130. package/src/commands/session/show.ts +26 -0
  131. package/src/commands/settings.ts +368 -0
  132. package/src/commands/setup.ts +23 -0
  133. package/src/commands/shell/index.ts +16 -0
  134. package/src/commands/shell/setup.ts +422 -0
  135. package/src/commands/shell-init.ts +50 -0
  136. package/src/commands/shell-integration/index.ts +194 -0
  137. package/src/commands/skill.ts +1220 -0
  138. package/src/commands/spec.ts +558 -0
  139. package/src/commands/status.ts +246 -0
  140. package/src/commands/theme.ts +211 -0
  141. package/src/commands/think.ts +551 -0
  142. package/src/commands/trust.ts +211 -0
  143. package/src/commands/tutorial.ts +522 -0
  144. package/src/commands/types.ts +512 -0
  145. package/src/commands/update.ts +274 -0
  146. package/src/commands/usage.ts +213 -0
  147. package/src/commands/user-commands.ts +630 -0
  148. package/src/commands/utils.ts +142 -0
  149. package/src/commands/vim.ts +152 -0
  150. package/src/commands/workflow.ts +257 -0
  151. package/src/components/header.tsx +25 -0
  152. package/src/components/input.tsx +25 -0
  153. package/src/components/message-list.tsx +32 -0
  154. package/src/components/status-bar.tsx +23 -0
  155. package/src/index.tsx +614 -0
  156. package/src/onboarding/__tests__/tutorial.test.ts +740 -0
  157. package/src/onboarding/index.ts +69 -0
  158. package/src/onboarding/tips/index.ts +9 -0
  159. package/src/onboarding/tips/tip-engine.ts +459 -0
  160. package/src/onboarding/tutorial/index.ts +88 -0
  161. package/src/onboarding/tutorial/lessons/basics.ts +151 -0
  162. package/src/onboarding/tutorial/lessons/index.ts +151 -0
  163. package/src/onboarding/tutorial/lessons/modes.ts +230 -0
  164. package/src/onboarding/tutorial/lessons/tools.ts +172 -0
  165. package/src/onboarding/tutorial/progress-tracker.ts +350 -0
  166. package/src/onboarding/tutorial/storage.ts +249 -0
  167. package/src/onboarding/tutorial/tutorial-system.ts +462 -0
  168. package/src/onboarding/tutorial/types.ts +310 -0
  169. package/src/orchestrator-singleton.ts +129 -0
  170. package/src/shutdown.ts +33 -0
  171. package/src/test/e2e/assertions.ts +267 -0
  172. package/src/test/e2e/fixtures.ts +204 -0
  173. package/src/test/e2e/harness.ts +575 -0
  174. package/src/test/e2e/index.ts +57 -0
  175. package/src/test/e2e/types.ts +228 -0
  176. package/src/test/fixtures/__tests__/fake-response-loader.test.ts +314 -0
  177. package/src/test/fixtures/fake-response-loader.ts +314 -0
  178. package/src/test/fixtures/index.ts +20 -0
  179. package/src/tui/__tests__/mcp-panel.test.tsx +82 -0
  180. package/src/tui/__tests__/mcp-wiring.test.tsx +78 -0
  181. package/src/tui/__tests__/mode-components.test.tsx +395 -0
  182. package/src/tui/__tests__/permission-ask-flow.test.tsx +138 -0
  183. package/src/tui/__tests__/sidebar-panel-data.test.tsx +148 -0
  184. package/src/tui/__tests__/tools-panel-hotkeys.test.tsx +41 -0
  185. package/src/tui/adapters/agent-adapter.ts +1008 -0
  186. package/src/tui/adapters/index.ts +48 -0
  187. package/src/tui/adapters/message-adapter.ts +315 -0
  188. package/src/tui/adapters/persistence-bridge.ts +331 -0
  189. package/src/tui/adapters/session-adapter.ts +419 -0
  190. package/src/tui/buffered-stdout.ts +223 -0
  191. package/src/tui/components/AgentProgress.tsx +424 -0
  192. package/src/tui/components/Banner/AsciiArt.ts +160 -0
  193. package/src/tui/components/Banner/Banner.tsx +355 -0
  194. package/src/tui/components/Banner/ShimmerContext.tsx +131 -0
  195. package/src/tui/components/Banner/ShimmerText.tsx +193 -0
  196. package/src/tui/components/Banner/TypeWriterGradient.tsx +321 -0
  197. package/src/tui/components/Banner/index.ts +61 -0
  198. package/src/tui/components/Banner/useShimmer.ts +241 -0
  199. package/src/tui/components/ChatView.tsx +11 -0
  200. package/src/tui/components/Checkpoint/CheckpointDiffView.tsx +371 -0
  201. package/src/tui/components/Checkpoint/SnapshotCheckpointPanel.tsx +440 -0
  202. package/src/tui/components/Checkpoint/index.ts +19 -0
  203. package/src/tui/components/CostDisplay.tsx +226 -0
  204. package/src/tui/components/InitErrorBanner.tsx +122 -0
  205. package/src/tui/components/Input/Autocomplete.tsx +603 -0
  206. package/src/tui/components/Input/EnhancedCommandInput.tsx +471 -0
  207. package/src/tui/components/Input/HighlightedText.tsx +236 -0
  208. package/src/tui/components/Input/MentionAutocomplete.tsx +375 -0
  209. package/src/tui/components/Input/TextInput.tsx +1002 -0
  210. package/src/tui/components/Input/__tests__/Autocomplete.test.tsx +374 -0
  211. package/src/tui/components/Input/__tests__/TextInput.test.tsx +241 -0
  212. package/src/tui/components/Input/__tests__/highlight.test.ts +219 -0
  213. package/src/tui/components/Input/__tests__/slash-command-utils.test.ts +104 -0
  214. package/src/tui/components/Input/highlight.ts +362 -0
  215. package/src/tui/components/Input/index.ts +36 -0
  216. package/src/tui/components/Input/slash-command-utils.ts +135 -0
  217. package/src/tui/components/Layout.tsx +432 -0
  218. package/src/tui/components/McpPanel.tsx +137 -0
  219. package/src/tui/components/MemoryPanel.tsx +448 -0
  220. package/src/tui/components/Messages/CodeBlock.tsx +527 -0
  221. package/src/tui/components/Messages/DiffView.tsx +679 -0
  222. package/src/tui/components/Messages/ImageReference.tsx +89 -0
  223. package/src/tui/components/Messages/MarkdownBlock.tsx +228 -0
  224. package/src/tui/components/Messages/MarkdownRenderer.tsx +498 -0
  225. package/src/tui/components/Messages/MessageBubble.tsx +270 -0
  226. package/src/tui/components/Messages/MessageList.tsx +1719 -0
  227. package/src/tui/components/Messages/StreamingText.tsx +216 -0
  228. package/src/tui/components/Messages/ThinkingBlock.tsx +408 -0
  229. package/src/tui/components/Messages/ToolResultPreview.tsx +243 -0
  230. package/src/tui/components/Messages/__tests__/CodeBlock.test.tsx +296 -0
  231. package/src/tui/components/Messages/__tests__/DiffView.test.tsx +239 -0
  232. package/src/tui/components/Messages/__tests__/MarkdownRenderer.test.tsx +303 -0
  233. package/src/tui/components/Messages/__tests__/MessageBubble.test.tsx +268 -0
  234. package/src/tui/components/Messages/__tests__/MessageList.test.tsx +324 -0
  235. package/src/tui/components/Messages/__tests__/StreamingText.test.tsx +215 -0
  236. package/src/tui/components/Messages/index.ts +25 -0
  237. package/src/tui/components/ModeIndicator.tsx +177 -0
  238. package/src/tui/components/ModeSelector.tsx +216 -0
  239. package/src/tui/components/ModelSelector.tsx +339 -0
  240. package/src/tui/components/OnboardingWizard.tsx +670 -0
  241. package/src/tui/components/PhaseProgressIndicator.tsx +270 -0
  242. package/src/tui/components/RateLimitIndicator.tsx +82 -0
  243. package/src/tui/components/ScreenReaderLayout.tsx +295 -0
  244. package/src/tui/components/SettingsPanel.tsx +643 -0
  245. package/src/tui/components/Sidebar/SystemStatusPanel.tsx +284 -0
  246. package/src/tui/components/Sidebar/index.ts +9 -0
  247. package/src/tui/components/Status/ModelStatusBar.tsx +270 -0
  248. package/src/tui/components/Status/index.ts +12 -0
  249. package/src/tui/components/StatusBar/AgentModeIndicator.tsx +257 -0
  250. package/src/tui/components/StatusBar/ContextProgress.tsx +167 -0
  251. package/src/tui/components/StatusBar/FileChangesIndicator.tsx +62 -0
  252. package/src/tui/components/StatusBar/GitIndicator.tsx +89 -0
  253. package/src/tui/components/StatusBar/HeaderBar.tsx +126 -0
  254. package/src/tui/components/StatusBar/ModelIndicator.tsx +157 -0
  255. package/src/tui/components/StatusBar/PersistenceStatusIndicator.tsx +210 -0
  256. package/src/tui/components/StatusBar/ResilienceIndicator.tsx +106 -0
  257. package/src/tui/components/StatusBar/SandboxIndicator.tsx +167 -0
  258. package/src/tui/components/StatusBar/StatusBar.tsx +368 -0
  259. package/src/tui/components/StatusBar/ThinkingModeIndicator.tsx +170 -0
  260. package/src/tui/components/StatusBar/TokenBreakdown.tsx +246 -0
  261. package/src/tui/components/StatusBar/TokenCounter.tsx +135 -0
  262. package/src/tui/components/StatusBar/TrustModeIndicator.tsx +130 -0
  263. package/src/tui/components/StatusBar/WorkspaceIndicator.tsx +86 -0
  264. package/src/tui/components/StatusBar/__tests__/AgentModeIndicator.test.tsx +193 -0
  265. package/src/tui/components/StatusBar/__tests__/StatusBar.test.tsx +729 -0
  266. package/src/tui/components/StatusBar/index.ts +60 -0
  267. package/src/tui/components/TipBanner.tsx +115 -0
  268. package/src/tui/components/TodoItem.tsx +208 -0
  269. package/src/tui/components/TodoPanel.tsx +455 -0
  270. package/src/tui/components/Tools/ApprovalQueue.tsx +407 -0
  271. package/src/tui/components/Tools/OptionSelector.tsx +160 -0
  272. package/src/tui/components/Tools/PermissionDialog.tsx +286 -0
  273. package/src/tui/components/Tools/ToolParams.tsx +483 -0
  274. package/src/tui/components/Tools/ToolsPanel.tsx +178 -0
  275. package/src/tui/components/Tools/__tests__/PermissionDialog.test.tsx +510 -0
  276. package/src/tui/components/Tools/__tests__/ToolParams.test.tsx +432 -0
  277. package/src/tui/components/Tools/index.ts +21 -0
  278. package/src/tui/components/TrustPrompt.tsx +279 -0
  279. package/src/tui/components/UpdateBanner.tsx +166 -0
  280. package/src/tui/components/VimModeIndicator.tsx +112 -0
  281. package/src/tui/components/backtrack/BacktrackControls.tsx +402 -0
  282. package/src/tui/components/backtrack/index.ts +13 -0
  283. package/src/tui/components/common/AutoApprovalStatus.tsx +251 -0
  284. package/src/tui/components/common/CostWarning.tsx +294 -0
  285. package/src/tui/components/common/DynamicShortcutHints.tsx +209 -0
  286. package/src/tui/components/common/EnhancedLoadingIndicator.tsx +305 -0
  287. package/src/tui/components/common/ErrorBoundary.tsx +140 -0
  288. package/src/tui/components/common/GradientText.tsx +224 -0
  289. package/src/tui/components/common/HotkeyHelpModal.tsx +193 -0
  290. package/src/tui/components/common/HotkeyHints.tsx +70 -0
  291. package/src/tui/components/common/MaxSizedBox.tsx +354 -0
  292. package/src/tui/components/common/NewMessagesBadge.tsx +65 -0
  293. package/src/tui/components/common/ProtectedFileLegend.tsx +89 -0
  294. package/src/tui/components/common/ScrollIndicator.tsx +160 -0
  295. package/src/tui/components/common/Spinner.tsx +342 -0
  296. package/src/tui/components/common/StreamingIndicator.tsx +316 -0
  297. package/src/tui/components/common/VirtualizedList/VirtualizedList.tsx +428 -0
  298. package/src/tui/components/common/VirtualizedList/hooks/index.ts +19 -0
  299. package/src/tui/components/common/VirtualizedList/hooks/useBatchedScroll.ts +64 -0
  300. package/src/tui/components/common/VirtualizedList/hooks/useScrollAnchor.ts +290 -0
  301. package/src/tui/components/common/VirtualizedList/hooks/useVirtualization.ts +340 -0
  302. package/src/tui/components/common/VirtualizedList/index.ts +30 -0
  303. package/src/tui/components/common/VirtualizedList/types.ts +107 -0
  304. package/src/tui/components/common/__tests__/NewMessagesBadge.test.tsx +74 -0
  305. package/src/tui/components/common/__tests__/ScrollIndicator.test.tsx +193 -0
  306. package/src/tui/components/common/index.ts +110 -0
  307. package/src/tui/components/index.ts +79 -0
  308. package/src/tui/components/session/CheckpointPanel.tsx +323 -0
  309. package/src/tui/components/session/RollbackDialog.tsx +169 -0
  310. package/src/tui/components/session/SessionItem.tsx +136 -0
  311. package/src/tui/components/session/SessionListPanel.tsx +252 -0
  312. package/src/tui/components/session/SessionPicker.tsx +449 -0
  313. package/src/tui/components/session/SessionPreview.tsx +240 -0
  314. package/src/tui/components/session/__tests__/session.test.tsx +408 -0
  315. package/src/tui/components/session/index.ts +28 -0
  316. package/src/tui/components/session/types.ts +116 -0
  317. package/src/tui/components/theme/__tests__/tokens.test.ts +471 -0
  318. package/src/tui/components/theme/index.ts +227 -0
  319. package/src/tui/components/theme/tokens.ts +484 -0
  320. package/src/tui/config/defaults.ts +134 -0
  321. package/src/tui/config/index.ts +17 -0
  322. package/src/tui/context/AnimationContext.tsx +284 -0
  323. package/src/tui/context/AppContext.tsx +349 -0
  324. package/src/tui/context/BracketedPasteContext.tsx +372 -0
  325. package/src/tui/context/LspContext.tsx +192 -0
  326. package/src/tui/context/McpContext.tsx +325 -0
  327. package/src/tui/context/MessagesContext.tsx +870 -0
  328. package/src/tui/context/OverflowContext.tsx +213 -0
  329. package/src/tui/context/RateLimitContext.tsx +108 -0
  330. package/src/tui/context/ResilienceContext.tsx +275 -0
  331. package/src/tui/context/RootProvider.tsx +136 -0
  332. package/src/tui/context/ScrollContext.tsx +331 -0
  333. package/src/tui/context/ToolsContext.tsx +702 -0
  334. package/src/tui/context/__tests__/BracketedPasteContext.test.tsx +416 -0
  335. package/src/tui/context/index.ts +140 -0
  336. package/src/tui/enterprise-integration.ts +282 -0
  337. package/src/tui/hooks/__tests__/useBacktrack.test.tsx +138 -0
  338. package/src/tui/hooks/__tests__/useBracketedPaste.test.tsx +222 -0
  339. package/src/tui/hooks/__tests__/useCopyMode.test.tsx +336 -0
  340. package/src/tui/hooks/__tests__/useHotkeys.ctrl-input.test.tsx +96 -0
  341. package/src/tui/hooks/__tests__/useHotkeys.test.tsx +454 -0
  342. package/src/tui/hooks/__tests__/useInputHistory.test.tsx +660 -0
  343. package/src/tui/hooks/__tests__/useLineBuffer.test.ts +295 -0
  344. package/src/tui/hooks/__tests__/useModeController.test.ts +137 -0
  345. package/src/tui/hooks/__tests__/useModeShortcuts.test.tsx +142 -0
  346. package/src/tui/hooks/__tests__/useScrollController.test.ts +464 -0
  347. package/src/tui/hooks/__tests__/useVim.test.tsx +531 -0
  348. package/src/tui/hooks/index.ts +252 -0
  349. package/src/tui/hooks/useAgentLoop.ts +712 -0
  350. package/src/tui/hooks/useAlternateBuffer.ts +398 -0
  351. package/src/tui/hooks/useAnimatedScrollbar.ts +241 -0
  352. package/src/tui/hooks/useBacktrack.ts +443 -0
  353. package/src/tui/hooks/useBracketedPaste.ts +104 -0
  354. package/src/tui/hooks/useCollapsible.ts +240 -0
  355. package/src/tui/hooks/useCopyMode.ts +382 -0
  356. package/src/tui/hooks/useCostSummary.ts +75 -0
  357. package/src/tui/hooks/useDesktopNotification.ts +414 -0
  358. package/src/tui/hooks/useDiffMode.ts +44 -0
  359. package/src/tui/hooks/useFileChangeStats.ts +110 -0
  360. package/src/tui/hooks/useFileSuggestions.ts +284 -0
  361. package/src/tui/hooks/useFlickerDetector.ts +250 -0
  362. package/src/tui/hooks/useGitStatus.ts +200 -0
  363. package/src/tui/hooks/useHotkeys.ts +579 -0
  364. package/src/tui/hooks/useImagePaste.ts +114 -0
  365. package/src/tui/hooks/useInputHighlight.ts +145 -0
  366. package/src/tui/hooks/useInputHistory.ts +246 -0
  367. package/src/tui/hooks/useKeyboardScroll.ts +209 -0
  368. package/src/tui/hooks/useLineBuffer.ts +356 -0
  369. package/src/tui/hooks/useMentionAutocomplete.ts +235 -0
  370. package/src/tui/hooks/useModeController.ts +167 -0
  371. package/src/tui/hooks/useModeShortcuts.ts +196 -0
  372. package/src/tui/hooks/usePermissionHandler.ts +146 -0
  373. package/src/tui/hooks/usePersistence.ts +480 -0
  374. package/src/tui/hooks/usePersistenceShortcuts.ts +225 -0
  375. package/src/tui/hooks/usePlaceholderRotation.ts +143 -0
  376. package/src/tui/hooks/useProviderStatus.ts +270 -0
  377. package/src/tui/hooks/useRateLimitStatus.ts +90 -0
  378. package/src/tui/hooks/useScreenReader.ts +315 -0
  379. package/src/tui/hooks/useScrollController.ts +450 -0
  380. package/src/tui/hooks/useScrollEventBatcher.ts +185 -0
  381. package/src/tui/hooks/useSidebarPanelData.ts +115 -0
  382. package/src/tui/hooks/useSmoothScroll.ts +202 -0
  383. package/src/tui/hooks/useSnapshots.ts +300 -0
  384. package/src/tui/hooks/useStateAndRef.ts +50 -0
  385. package/src/tui/hooks/useTerminalSize.ts +206 -0
  386. package/src/tui/hooks/useToolApprovalController.ts +91 -0
  387. package/src/tui/hooks/useVim.ts +334 -0
  388. package/src/tui/hooks/useWorkspace.ts +56 -0
  389. package/src/tui/i18n/__tests__/init.test.ts +278 -0
  390. package/src/tui/i18n/__tests__/language-config.test.ts +199 -0
  391. package/src/tui/i18n/__tests__/locale-detection.test.ts +250 -0
  392. package/src/tui/i18n/__tests__/settings-integration.test.ts +262 -0
  393. package/src/tui/i18n/index.ts +72 -0
  394. package/src/tui/i18n/init.ts +131 -0
  395. package/src/tui/i18n/language-config.ts +106 -0
  396. package/src/tui/i18n/locale-detection.ts +173 -0
  397. package/src/tui/i18n/settings-integration.ts +557 -0
  398. package/src/tui/i18n/tui-namespace.ts +538 -0
  399. package/src/tui/i18n/types.ts +312 -0
  400. package/src/tui/index.ts +43 -0
  401. package/src/tui/lsp-integration.ts +409 -0
  402. package/src/tui/metrics-integration.ts +366 -0
  403. package/src/tui/plugins.ts +383 -0
  404. package/src/tui/resilience.ts +342 -0
  405. package/src/tui/sandbox-integration.ts +317 -0
  406. package/src/tui/services/clipboard.ts +348 -0
  407. package/src/tui/services/fuzzy-search.ts +441 -0
  408. package/src/tui/services/index.ts +72 -0
  409. package/src/tui/services/markdown-renderer.ts +565 -0
  410. package/src/tui/services/open-external.ts +247 -0
  411. package/src/tui/services/syntax-highlighter.ts +483 -0
  412. package/src/tui/slash-commands.ts +12 -0
  413. package/src/tui/theme/index.ts +15 -0
  414. package/src/tui/theme/provider.tsx +206 -0
  415. package/src/tui/tip-integration.ts +300 -0
  416. package/src/tui/types/__tests__/ink-extended.test.ts +121 -0
  417. package/src/tui/types/ink-extended.ts +87 -0
  418. package/src/tui/utils/__tests__/bracketedPaste.test.ts +231 -0
  419. package/src/tui/utils/__tests__/heightEstimator.test.ts +157 -0
  420. package/src/tui/utils/__tests__/text-width.test.ts +158 -0
  421. package/src/tui/utils/__tests__/textSanitizer.test.ts +266 -0
  422. package/src/tui/utils/__tests__/ui-sizing.test.ts +169 -0
  423. package/src/tui/utils/bracketedPaste.ts +107 -0
  424. package/src/tui/utils/cursor-manager.ts +131 -0
  425. package/src/tui/utils/detectTerminal.ts +596 -0
  426. package/src/tui/utils/findLastSafeSplitPoint.ts +92 -0
  427. package/src/tui/utils/heightEstimator.ts +198 -0
  428. package/src/tui/utils/index.ts +91 -0
  429. package/src/tui/utils/isNarrowWidth.ts +52 -0
  430. package/src/tui/utils/stdoutGuard.ts +90 -0
  431. package/src/tui/utils/synchronized-update.ts +70 -0
  432. package/src/tui/utils/text-width.ts +225 -0
  433. package/src/tui/utils/textSanitizer.ts +225 -0
  434. package/src/tui/utils/textUtils.ts +114 -0
  435. package/src/tui/utils/ui-sizing.ts +192 -0
  436. package/src/tui-blessed/app.ts +160 -0
  437. package/src/tui-blessed/index.ts +2 -0
  438. package/src/tui-blessed/neo-blessed.d.ts +6 -0
  439. package/src/tui-blessed/test.ts +21 -0
  440. package/src/tui-blessed/types.ts +14 -0
  441. package/src/utils/icons.ts +130 -0
  442. package/src/utils/index.ts +33 -0
  443. package/src/utils/resume-hint.ts +86 -0
  444. package/src/version.ts +1 -0
  445. package/tsconfig.json +8 -0
  446. package/vitest.config.ts +35 -0
package/src/app.tsx ADDED
@@ -0,0 +1,4737 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type {
4
+ AgentLoop,
5
+ ApprovalPolicy,
6
+ CodingMode,
7
+ EnterpriseHooks as CoreEnterpriseHooks,
8
+ CredentialManager,
9
+ EnterpriseToolCallInfo,
10
+ SandboxPolicy,
11
+ Session,
12
+ SessionMode,
13
+ TaskChain,
14
+ TaskChainNode,
15
+ ToolExecutor,
16
+ ToolRegistry,
17
+ } from "@vellum/core";
18
+ import {
19
+ BUILTIN_CODING_MODES,
20
+ OnboardingWizard as CoreOnboardingWizard,
21
+ createCostService,
22
+ createModeManager,
23
+ createSession,
24
+ createToolRegistry,
25
+ createUserMessage,
26
+ getTextContent,
27
+ ProjectMemoryService,
28
+ registerAllBuiltinTools,
29
+ registerGitTools,
30
+ SearchService,
31
+ SessionListService,
32
+ SessionParts,
33
+ StorageManager,
34
+ setBatchToolRegistry,
35
+ setTuiModeActive,
36
+ updateSessionMetadata,
37
+ } from "@vellum/core";
38
+ import { createId } from "@vellum/shared";
39
+ import { Box, Text, useApp as useInkApp, useInput, useStdout } from "ink";
40
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
41
+ import type { DefaultContextProvider } from "./commands/index.js";
42
+ import {
43
+ agentsCommand,
44
+ CommandExecutor,
45
+ CommandRegistry,
46
+ clearCommand,
47
+ condenseCommand,
48
+ configSlashCommands,
49
+ costCommand,
50
+ costResetCommand,
51
+ createBatchCommand,
52
+ createContextProvider,
53
+ createCredentialManager,
54
+ createResumeCommand,
55
+ createSearchCommand,
56
+ customAgentsCommand,
57
+ diffModeSlashCommands,
58
+ enhancedAuthCommands,
59
+ exitCommand,
60
+ getEffectiveThinkingConfig,
61
+ getThinkingState,
62
+ helpCommand,
63
+ initSlashCommand,
64
+ languageCommand,
65
+ memoryCommand,
66
+ metricsCommands,
67
+ modelCommand,
68
+ onboardCommand,
69
+ persistenceCommands,
70
+ promptPrioritySlashCommands,
71
+ type ResumeSessionEventData,
72
+ registerUserCommands,
73
+ setCondenseCommandLoop,
74
+ setCostCommandsService,
75
+ setHelpRegistry,
76
+ setModeCommandsManager,
77
+ setModelCommandConfig,
78
+ setPersistenceRef,
79
+ setThemeContext,
80
+ settingsSlashCommands,
81
+ setVimCallbacks,
82
+ subscribeToThinkingState,
83
+ themeSlashCommands,
84
+ thinkSlashCommands,
85
+ toggleThinking,
86
+ tutorialCommand,
87
+ vimSlashCommands,
88
+ } from "./commands/index.js";
89
+ import { modeSlashCommands } from "./commands/mode.js";
90
+ import type { AsyncOperation, CommandResult, InteractivePrompt } from "./commands/types.js";
91
+ import { setShutdownCleanup } from "./shutdown.js";
92
+ import { useAgentAdapter } from "./tui/adapters/agent-adapter.js";
93
+ import { toUIMessages } from "./tui/adapters/message-adapter.js";
94
+ import {
95
+ createMemorySessionStorage,
96
+ type SessionStorage,
97
+ useSessionAdapter,
98
+ } from "./tui/adapters/session-adapter.js";
99
+ import { AgentProgress } from "./tui/components/AgentProgress.js";
100
+ import { Banner } from "./tui/components/Banner/index.js";
101
+ import { BacktrackControls } from "./tui/components/backtrack/BacktrackControls.js";
102
+ import { CheckpointDiffView } from "./tui/components/Checkpoint/CheckpointDiffView.js";
103
+ import { SnapshotCheckpointPanel } from "./tui/components/Checkpoint/SnapshotCheckpointPanel.js";
104
+ import { CostDisplay } from "./tui/components/CostDisplay.js";
105
+ // New status components (Phase 35+)
106
+ import { AutoApprovalStatus } from "./tui/components/common/AutoApprovalStatus.js";
107
+ import { CostWarning } from "./tui/components/common/CostWarning.js";
108
+ import { ErrorBoundary } from "./tui/components/common/ErrorBoundary.js";
109
+ import { DEFAULT_HOTKEYS, HotkeyHelpModal } from "./tui/components/common/HotkeyHelpModal.js";
110
+ import { MaxSizedBox } from "./tui/components/common/MaxSizedBox.js";
111
+ import { LoadingIndicator } from "./tui/components/common/Spinner.js";
112
+ import type { AutocompleteOption } from "./tui/components/Input/Autocomplete.js";
113
+ import { EnhancedCommandInput } from "./tui/components/Input/EnhancedCommandInput.js";
114
+ import type { SlashCommand } from "./tui/components/Input/slash-command-utils.js";
115
+ import { TextInput } from "./tui/components/Input/TextInput.js";
116
+ import { InitErrorBanner, McpPanel } from "./tui/components/index.js";
117
+ import { Layout } from "./tui/components/Layout.js";
118
+ import { MemoryPanel, type MemoryPanelProps } from "./tui/components/MemoryPanel.js";
119
+ import { MessageList } from "./tui/components/Messages/MessageList.js";
120
+ import { ModeIndicator } from "./tui/components/ModeIndicator.js";
121
+ import { ModelSelector } from "./tui/components/ModelSelector.js";
122
+ import { ModeSelector } from "./tui/components/ModeSelector.js";
123
+ import { OnboardingWizard } from "./tui/components/OnboardingWizard.js";
124
+ import { PhaseProgressIndicator } from "./tui/components/PhaseProgressIndicator.js";
125
+ import { AdaptiveLayout } from "./tui/components/ScreenReaderLayout.js";
126
+ import { SystemStatusPanel } from "./tui/components/Sidebar/SystemStatusPanel.js";
127
+ import { ModelStatusBar } from "./tui/components/Status/ModelStatusBar.js";
128
+ import { FileChangesIndicator } from "./tui/components/StatusBar/FileChangesIndicator.js";
129
+ import { StatusBar } from "./tui/components/StatusBar/StatusBar.js";
130
+ import type { TrustMode } from "./tui/components/StatusBar/TrustModeIndicator.js";
131
+ import { SessionPicker } from "./tui/components/session/SessionPicker.js";
132
+ // Note: ProtectedFileLegend is rendered by tool output formatters, not app.tsx directly
133
+ import type { SessionMetadata, SessionPreviewMessage } from "./tui/components/session/types.js";
134
+ import { TipBanner } from "./tui/components/TipBanner.js";
135
+ import type { TodoItemData } from "./tui/components/TodoItem.js";
136
+ import { TodoPanel } from "./tui/components/TodoPanel.js";
137
+ import { ApprovalQueue } from "./tui/components/Tools/ApprovalQueue.js";
138
+ import { OptionSelector } from "./tui/components/Tools/OptionSelector.js";
139
+ import { PermissionDialog } from "./tui/components/Tools/PermissionDialog.js";
140
+ import { ToolsPanel } from "./tui/components/Tools/ToolsPanel.js";
141
+ import { UpdateBanner } from "./tui/components/UpdateBanner.js";
142
+ import { VimModeIndicator } from "./tui/components/VimModeIndicator.js";
143
+ import type { Message } from "./tui/context/MessagesContext.js";
144
+ import { useMessages } from "./tui/context/MessagesContext.js";
145
+ import { RootProvider } from "./tui/context/RootProvider.js";
146
+ import { type ToolExecution, useTools } from "./tui/context/ToolsContext.js";
147
+ import {
148
+ type PersistenceStatus,
149
+ usePersistence,
150
+ usePersistenceShortcuts,
151
+ } from "./tui/hooks/index.js";
152
+ import { useAlternateBuffer } from "./tui/hooks/useAlternateBuffer.js";
153
+ import { useBacktrack } from "./tui/hooks/useBacktrack.js";
154
+ import { useCopyMode } from "./tui/hooks/useCopyMode.js";
155
+ import { useDesktopNotification } from "./tui/hooks/useDesktopNotification.js";
156
+ import { useFileChangeStats } from "./tui/hooks/useFileChangeStats.js";
157
+ import { useGitStatus } from "./tui/hooks/useGitStatus.js";
158
+ import { type HotkeyDefinition, useHotkeys } from "./tui/hooks/useHotkeys.js";
159
+ import { useInputHistory } from "./tui/hooks/useInputHistory.js";
160
+ import { useModeShortcuts } from "./tui/hooks/useModeShortcuts.js";
161
+ import { useProviderStatus } from "./tui/hooks/useProviderStatus.js";
162
+ import { isScreenReaderEnabled, useScreenReader } from "./tui/hooks/useScreenReader.js";
163
+ import { type SidebarContent, useSidebarPanelData } from "./tui/hooks/useSidebarPanelData.js";
164
+ import { useSnapshots } from "./tui/hooks/useSnapshots.js";
165
+ import { useToolApprovalController } from "./tui/hooks/useToolApprovalController.js";
166
+ import type { VimMode } from "./tui/hooks/useVim.js";
167
+ import { useVim } from "./tui/hooks/useVim.js";
168
+ import { useWorkspace } from "./tui/hooks/useWorkspace.js";
169
+ import {
170
+ getAlternateBufferEnabled,
171
+ getBannerSeen,
172
+ setBannerSeen as saveBannerSeen,
173
+ } from "./tui/i18n/settings-integration.js";
174
+ import {
175
+ disposeLsp,
176
+ initializeLsp,
177
+ type LspIntegrationOptions,
178
+ type LspIntegrationResult,
179
+ } from "./tui/lsp-integration.js";
180
+ import {
181
+ disposePlugins,
182
+ getPluginCommands,
183
+ initializePlugins,
184
+ type PluginInitResult,
185
+ } from "./tui/plugins.js";
186
+
187
+ // =============================================================================
188
+ // Feature Integrations-
189
+ // =============================================================================
190
+
191
+ import { getProviderModels } from "@vellum/provider";
192
+ // Enterprise integration
193
+ import {
194
+ createEnterpriseHooks,
195
+ type EnterpriseHooks,
196
+ initializeEnterprise,
197
+ shutdownEnterprise,
198
+ } from "./tui/enterprise-integration.js";
199
+ // Metrics integration
200
+ import { getMetricsManager, type TuiMetricsManager } from "./tui/metrics-integration.js";
201
+ // Resilience integration-
202
+ import { createResilientProvider, type ResilientProvider } from "./tui/resilience.js";
203
+ // Sandbox integration
204
+ import { cleanupSandbox, initializeSandbox } from "./tui/sandbox-integration.js";
205
+ import { type ThemeName, useTheme } from "./tui/theme/index.js";
206
+ // Tip integration
207
+ import { buildTipContext, useTipEngine } from "./tui/tip-integration.js";
208
+ // Cursor management utilities
209
+ import { CursorManager } from "./tui/utils/cursor-manager.js";
210
+ import { calculateCost, getContextWindow, getModelInfo } from "./utils/index.js";
211
+
212
+ /**
213
+ * Get default model for a given provider
214
+ */
215
+ function getDefaultModelForProvider(provider: string): string {
216
+ const defaults: Record<string, string> = {
217
+ anthropic: "claude-sonnet-4-20250514",
218
+ openai: "gpt-4o",
219
+ google: "gemini-2.0-flash",
220
+ "azure-openai": "gpt-4o",
221
+ gemini: "gemini-2.0-flash",
222
+ "vertex-ai": "gemini-2.0-flash",
223
+ cohere: "command-r-plus",
224
+ mistral: "mistral-large-latest",
225
+ groq: "llama-3.3-70b-versatile",
226
+ fireworks: "accounts/fireworks/models/llama-v3p1-70b-instruct",
227
+ together: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
228
+ perplexity: "llama-3.1-sonar-large-128k-online",
229
+ bedrock: "anthropic.claude-3-5-sonnet-20241022-v2:0",
230
+ ollama: "llama3.2",
231
+ openrouter: "anthropic/claude-3.5-sonnet",
232
+ deepseek: "deepseek-chat",
233
+ qwen: "qwen-max",
234
+ moonshot: "moonshot-v1-8k",
235
+ };
236
+ return defaults[provider] ?? "claude-sonnet-4-20250514";
237
+ }
238
+
239
+ function approvalPolicyToTrustMode(policy: ApprovalPolicy): TrustMode {
240
+ switch (policy) {
241
+ case "suggest":
242
+ return "ask";
243
+ case "auto-edit":
244
+ case "on-request":
245
+ return "auto";
246
+ case "full-auto":
247
+ return "full";
248
+ }
249
+ }
250
+
251
+ function getDefaultApprovalPolicyForMode(mode: CodingMode): ApprovalPolicy {
252
+ switch (mode) {
253
+ case "vibe":
254
+ return "full-auto";
255
+ case "plan":
256
+ return "auto-edit";
257
+ case "spec":
258
+ return "suggest";
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Props for the App component.
264
+ * Extended with coding mode options-.
265
+ */
266
+ interface AppProps {
267
+ /** Model to use for AI responses */
268
+ model: string;
269
+ /** Provider to use (anthropic, openai, etc.) */
270
+ provider: string;
271
+ /** Initial coding mode */
272
+ mode?: CodingMode;
273
+ /** Approval policy override */
274
+ approval?: ApprovalPolicy;
275
+ /** Sandbox policy override */
276
+ sandbox?: SandboxPolicy;
277
+ /** Optional AgentLoop instance for real agent integration */
278
+ agentLoop?: AgentLoop;
279
+ /** Optional shared ToolRegistry for the running tool system (avoids internal registry duplication) */
280
+ toolRegistry?: ToolRegistry;
281
+ /** Optional shared ToolExecutor for executing tools (defaults to AgentLoop's executor when available) */
282
+ toolExecutor?: ToolExecutor;
283
+ /** UI theme (dark, parchment, dracula, etc.) */
284
+ theme?: ThemeName;
285
+ /** Force banner display on startup */
286
+ banner?: boolean;
287
+ /** Initialization error (when provider fails to initialize) */
288
+ initError?: Error;
289
+ }
290
+
291
+ type AppContentProps = AppProps & {
292
+ readonly toolRegistry: ToolRegistry;
293
+ };
294
+
295
+ /**
296
+ * Cancellation controller for the current agent operation.
297
+ * Used to wire Ctrl+C and ESC to cancel running operations.
298
+ */
299
+ interface CancellationController {
300
+ cancel: (reason?: string) => void;
301
+ isCancelled: boolean;
302
+ }
303
+
304
+ /**
305
+ * Map coding mode to session mode for persistence.
306
+ */
307
+ function mapCodingModeToSessionMode(mode: CodingMode): SessionMode {
308
+ switch (mode) {
309
+ case "vibe":
310
+ return "code";
311
+ case "plan":
312
+ return "plan";
313
+ case "spec":
314
+ return "plan";
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Derive a session title from messages.
320
+ */
321
+ type SessionMessage = Session["messages"][number];
322
+
323
+ function buildSessionTitle(messages: readonly SessionMessage[]): string {
324
+ const firstUser = messages.find((message) => message.role === "user");
325
+ const content = firstUser ? getTextContent(firstUser).trim() : "";
326
+ if (!content) {
327
+ return "New Session";
328
+ }
329
+ return content.length > 60 ? `${content.slice(0, 57)}...` : content;
330
+ }
331
+
332
+ /**
333
+ * Derive a summary/preview from messages.
334
+ */
335
+ function buildSessionSummary(messages: readonly SessionMessage[]): string | undefined {
336
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
337
+ const message = messages[i];
338
+ if (!message) {
339
+ continue;
340
+ }
341
+ const content = getTextContent(message).trim();
342
+ if (content) {
343
+ return content.length > 140 ? `${content.slice(0, 137)}...` : content;
344
+ }
345
+ }
346
+ return undefined;
347
+ }
348
+
349
+ // =============================================================================
350
+ // Task 2: Focus Debug Component
351
+ // =============================================================================
352
+
353
+ interface FocusDebuggerProps {
354
+ isLoading: boolean;
355
+ showModeSelector: boolean;
356
+ showModelSelector: boolean;
357
+ showSessionManager: boolean;
358
+ showHelpModal: boolean;
359
+ activeApproval: unknown;
360
+ interactivePrompt: unknown;
361
+ pendingOperation: unknown;
362
+ }
363
+
364
+ /**
365
+ * Debug component that logs focus conditions when they change.
366
+ * Helps diagnose input focus issues.
367
+ */
368
+ function FocusDebugger({
369
+ isLoading,
370
+ showModeSelector,
371
+ showModelSelector,
372
+ showSessionManager,
373
+ showHelpModal,
374
+ activeApproval,
375
+ interactivePrompt,
376
+ pendingOperation,
377
+ }: FocusDebuggerProps): null {
378
+ const shouldFocus =
379
+ !isLoading &&
380
+ !showModeSelector &&
381
+ !showModelSelector &&
382
+ !showSessionManager &&
383
+ !showHelpModal &&
384
+ !activeApproval &&
385
+ !interactivePrompt &&
386
+ !pendingOperation;
387
+
388
+ useEffect(() => {
389
+ console.log("[Focus Debug]", {
390
+ isLoading,
391
+ showModeSelector,
392
+ showModelSelector,
393
+ showSessionManager,
394
+ showHelpModal,
395
+ activeApproval: !!activeApproval,
396
+ interactivePrompt: !!interactivePrompt,
397
+ pendingOperation: !!pendingOperation,
398
+ shouldFocus,
399
+ });
400
+ }, [
401
+ shouldFocus,
402
+ isLoading,
403
+ showModeSelector,
404
+ showModelSelector,
405
+ showSessionManager,
406
+ showHelpModal,
407
+ activeApproval,
408
+ interactivePrompt,
409
+ pendingOperation,
410
+ ]);
411
+
412
+ return null;
413
+ }
414
+
415
+ // =============================================================================
416
+ //: Command Registry Initialization
417
+ // =============================================================================
418
+
419
+ /**
420
+ * Create and initialize the command registry with all builtin commands
421
+ */
422
+ function createCommandRegistry(): CommandRegistry {
423
+ const registry = new CommandRegistry();
424
+
425
+ // Register core system commands
426
+ registry.register(helpCommand);
427
+ registry.register(clearCommand);
428
+ registry.register(exitCommand);
429
+
430
+ // Register additional builtin commands
431
+ registry.register(languageCommand);
432
+ registry.register(modelCommand);
433
+ registry.register(costCommand);
434
+ registry.register(costResetCommand);
435
+ registry.register(initSlashCommand);
436
+ registry.register(onboardCommand);
437
+ registry.register(agentsCommand);
438
+ registry.register(customAgentsCommand);
439
+
440
+ // Register memory dispatcher (subcommands handled via /memory)
441
+ registry.register(memoryCommand);
442
+
443
+ // Register tutorial command (Phase 38)
444
+ registry.register(tutorialCommand);
445
+
446
+ // Register auth commands
447
+ for (const cmd of enhancedAuthCommands) {
448
+ registry.register(cmd);
449
+ }
450
+
451
+ //: Register mode slash commands
452
+ for (const cmd of modeSlashCommands) {
453
+ registry.register(cmd);
454
+ }
455
+
456
+ //: Register vim slash commands
457
+ for (const cmd of vimSlashCommands) {
458
+ registry.register(cmd);
459
+ }
460
+
461
+ // Register think slash commands
462
+ for (const cmd of thinkSlashCommands) {
463
+ registry.register(cmd);
464
+ }
465
+
466
+ // Register diff-mode slash commands
467
+ for (const cmd of diffModeSlashCommands) {
468
+ registry.register(cmd);
469
+ }
470
+
471
+ //: Register context management command
472
+ registry.register(condenseCommand);
473
+
474
+ //: Register theme slash commands
475
+ for (const cmd of themeSlashCommands) {
476
+ registry.register(cmd);
477
+ }
478
+
479
+ //: Register metrics commands
480
+ for (const cmd of metricsCommands) {
481
+ registry.register(cmd);
482
+ }
483
+
484
+ // Register persistence commands
485
+ for (const cmd of persistenceCommands) {
486
+ registry.register(cmd);
487
+ }
488
+
489
+ // Register settings system commands
490
+ for (const cmd of settingsSlashCommands) {
491
+ registry.register(cmd);
492
+ }
493
+
494
+ for (const cmd of configSlashCommands) {
495
+ registry.register(cmd);
496
+ }
497
+
498
+ for (const cmd of promptPrioritySlashCommands) {
499
+ registry.register(cmd);
500
+ }
501
+
502
+ //: Plugin commands are registered via registerPluginCommands()
503
+ // after PluginManager initialization in AppContent
504
+
505
+ // Wire up help command to access registry
506
+ setHelpRegistry(registry);
507
+
508
+ return registry;
509
+ }
510
+
511
+ /**
512
+ * Registers plugin commands into the command registry.
513
+ * Called after plugin initialization completes.
514
+ *
515
+ * @param registry - Command registry to register commands into
516
+ * @param pluginResult - Result from plugin initialization
517
+ */
518
+ function registerPluginCommands(registry: CommandRegistry, pluginResult: PluginInitResult): void {
519
+ const commands = getPluginCommands(pluginResult.manager);
520
+ for (const cmd of commands) {
521
+ try {
522
+ registry.register(cmd);
523
+ } catch (error) {
524
+ // Log but don't fail on command conflicts
525
+ console.warn(`[plugin] Failed to register command '${cmd.name}':`, error);
526
+ }
527
+ }
528
+ }
529
+
530
+ export function App({
531
+ model,
532
+ provider,
533
+ mode: _mode = "vibe",
534
+ approval: _approval,
535
+ sandbox: _sandbox,
536
+ agentLoop: agentLoopProp,
537
+ toolRegistry: toolRegistryProp,
538
+ toolExecutor: toolExecutorProp,
539
+ theme = "parchment",
540
+ banner,
541
+ initError,
542
+ }: AppProps) {
543
+ // Shared tool registry for the running tool system.
544
+ // This registry is used by commands, the tools UI, and MCP tool registration.
545
+ const toolRegistry = useMemo(() => {
546
+ if (toolRegistryProp) {
547
+ return toolRegistryProp;
548
+ }
549
+
550
+ const registry = createToolRegistry();
551
+ registerAllBuiltinTools(registry);
552
+ registerGitTools(registry);
553
+ setBatchToolRegistry(registry);
554
+ return registry;
555
+ }, [toolRegistryProp]);
556
+
557
+ // If an AgentLoop is provided, MCP tools must execute via the same ToolExecutor.
558
+ const toolExecutor: ToolExecutor | undefined = useMemo(() => {
559
+ if (toolExecutorProp) {
560
+ return toolExecutorProp;
561
+ }
562
+ return agentLoopProp?.getToolExecutor();
563
+ }, [agentLoopProp, toolExecutorProp]);
564
+
565
+ return (
566
+ <RootProvider theme={theme} toolRegistry={toolRegistry} toolExecutor={toolExecutor}>
567
+ <ErrorBoundary
568
+ onError={(error, errorInfo) => {
569
+ console.error("[ErrorBoundary] Caught error:", error, errorInfo);
570
+ }}
571
+ showDetails
572
+ >
573
+ <AppContent
574
+ model={model}
575
+ provider={provider}
576
+ mode={_mode}
577
+ approval={_approval}
578
+ sandbox={_sandbox}
579
+ agentLoop={agentLoopProp}
580
+ toolRegistry={toolRegistry}
581
+ banner={banner}
582
+ initError={initError}
583
+ />
584
+ </ErrorBoundary>
585
+ </RootProvider>
586
+ );
587
+ }
588
+
589
+ /**
590
+ * Inner component that contains the actual app logic.
591
+ * Separated from App to access ThemeContext via useTheme().
592
+ */
593
+ function AppContent({
594
+ model,
595
+ provider,
596
+ mode: _mode = "vibe",
597
+ approval: _approval,
598
+ sandbox: _sandbox,
599
+ agentLoop: agentLoopProp,
600
+ banner,
601
+ toolRegistry,
602
+ initError,
603
+ }: AppContentProps) {
604
+ const { exit } = useInkApp();
605
+ const themeContext = useTheme();
606
+ const { messages, addMessage, clearMessages, setMessages, pendingMessage } = useMessages();
607
+ const [isLoading, setIsLoading] = useState(false);
608
+ const [interactivePrompt, setInteractivePrompt] = useState<InteractivePrompt | null>(null);
609
+ const [followupPrompt, setFollowupPrompt] = useState<{
610
+ question: string;
611
+ suggestions: string[];
612
+ } | null>(null);
613
+ const [promptValue, setPromptValue] = useState("");
614
+ const [pendingOperation, setPendingOperation] = useState<AsyncOperation | null>(null);
615
+
616
+ // Suppress initial Enter key event when interactive prompt is mounted (fixes race condition)
617
+ const [suppressPromptEnter, setSuppressPromptEnter] = useState(false);
618
+ useEffect(() => {
619
+ if (interactivePrompt) {
620
+ setSuppressPromptEnter(true);
621
+ const timer = setTimeout(() => setSuppressPromptEnter(false), 50);
622
+ return () => clearTimeout(timer);
623
+ }
624
+ }, [interactivePrompt]);
625
+
626
+ // ==========================================================================
627
+ // New TUI Hooks Integration-
628
+ // ==========================================================================
629
+
630
+ // Vim modal editing mode
631
+ const [vimEnabled, setVimEnabled] = useState(false);
632
+ const vim = useVim();
633
+
634
+ // Wire up vim callbacks for /vim command
635
+ useEffect(() => {
636
+ const handleToggle = () => {
637
+ setVimEnabled((prev) => !prev);
638
+ vim.toggle();
639
+ };
640
+ const isEnabled = () => vimEnabled;
641
+ setVimCallbacks(handleToggle, isEnabled);
642
+ return () =>
643
+ setVimCallbacks(
644
+ () => {},
645
+ () => false
646
+ );
647
+ }, [vim, vimEnabled]);
648
+
649
+ // Copy mode for visual selection
650
+ const copyMode = useCopyMode();
651
+
652
+ // Desktop notifications
653
+ const {
654
+ notify: _notify,
655
+ notifyTaskComplete,
656
+ notifyError,
657
+ } = useDesktopNotification({ enabled: true });
658
+
659
+ // Workspace and git status for header separator
660
+ const { name: workspaceName } = useWorkspace();
661
+ const { branch: gitBranch, changedFiles: gitChangedFiles } = useGitStatus();
662
+
663
+ // Alternate buffer configuration
664
+ // Enabled by default (config defaults to true)
665
+ // Automatically disabled when screen reader is detected for accessibility
666
+ const { stdout } = useStdout();
667
+ const alternateBufferConfig = getAlternateBufferEnabled();
668
+ const screenReaderDetected = isScreenReaderEnabled();
669
+ // Enable alternate buffer in VS Code terminal to fix cursor flickering
670
+ const alternateBufferEnabled = alternateBufferConfig && !screenReaderDetected;
671
+
672
+ // Alternate buffer for full-screen rendering
673
+ // Benefits: Clean exit (restores original terminal), no scrollback pollution
674
+ const alternateBuffer = useAlternateBuffer({
675
+ enabled: alternateBufferEnabled,
676
+ });
677
+ // Destructure for convenience
678
+ const { isAlternate } = alternateBuffer;
679
+
680
+ // Terminal height for layout constraint when in alternate buffer mode
681
+ const terminalHeight = process.stdout.rows || 24;
682
+ void isAlternate; // Used for layout height calculation
683
+ void terminalHeight; // Used for layout height calculation
684
+
685
+ // Hide the terminal cursor to avoid VS Code's blinking block over the message area.
686
+ // We draw our own cursor in inputs and streaming text.
687
+ // Uses centralized CursorManager to prevent race conditions.
688
+ useEffect(() => {
689
+ if (screenReaderDetected || !stdout.isTTY) {
690
+ return;
691
+ }
692
+ if (process.env.VELLUM_SHOW_CURSOR === "1") {
693
+ return;
694
+ }
695
+
696
+ // Lock cursor in hidden state for entire TUI session
697
+ CursorManager.lock();
698
+
699
+ // Setup exit handlers to restore cursor
700
+ const handleExit = (): void => {
701
+ CursorManager.unlock();
702
+ CursorManager.forceShow();
703
+ };
704
+
705
+ process.on("exit", handleExit);
706
+ process.on("SIGINT", handleExit);
707
+ process.on("SIGTERM", handleExit);
708
+ process.on("SIGHUP", handleExit);
709
+
710
+ return () => {
711
+ process.off("exit", handleExit);
712
+ process.off("SIGINT", handleExit);
713
+ process.off("SIGTERM", handleExit);
714
+ process.off("SIGHUP", handleExit);
715
+ CursorManager.unlock();
716
+ CursorManager.forceShow();
717
+ };
718
+ }, [screenReaderDetected, stdout]);
719
+
720
+ // ==========================================================================
721
+ // TUI Mode Console Suppression (Overflow Prevention)
722
+ // ==========================================================================
723
+ // Enable TUI mode to suppress console.log output from loggers.
724
+ // This prevents console output from bypassing Ink and causing terminal overflow.
725
+ useEffect(() => {
726
+ // Activate TUI mode to suppress console transport
727
+ setTuiModeActive(true);
728
+
729
+ // Setup exit handlers to restore console on exit
730
+ const restoreConsole = (): void => {
731
+ setTuiModeActive(false);
732
+ };
733
+
734
+ // Standard exit signals
735
+ process.on("exit", restoreConsole);
736
+ process.on("SIGINT", restoreConsole);
737
+ process.on("SIGTERM", restoreConsole);
738
+
739
+ // Exception handlers for complete coverage Hardening)
740
+ // These ensure TUI mode is disabled even on unexpected crashes
741
+ process.on("uncaughtException", restoreConsole);
742
+ process.on("unhandledRejection", restoreConsole);
743
+
744
+ return () => {
745
+ process.off("exit", restoreConsole);
746
+ process.off("SIGINT", restoreConsole);
747
+ process.off("SIGTERM", restoreConsole);
748
+ process.off("uncaughtException", restoreConsole);
749
+ process.off("unhandledRejection", restoreConsole);
750
+ setTuiModeActive(false);
751
+ };
752
+ }, []);
753
+
754
+ // NOTE: Previous useInput and setInterval for cursor hiding removed.
755
+ // CursorManager.lock() now handles cursor state centrally, preventing
756
+ // race conditions and flickering from multiple cursor hide/show operations.
757
+
758
+ // ==========================================================================
759
+ // Feature Integrations-
760
+ // ==========================================================================
761
+
762
+ //: Sandbox integration for shell tool execution
763
+ const sandboxRef = useRef<ReturnType<typeof initializeSandbox> | null>(null);
764
+ useEffect(() => {
765
+ // Initialize sandbox on mount
766
+ sandboxRef.current = initializeSandbox({
767
+ workingDirectory: process.cwd(),
768
+ allowNetwork: false,
769
+ allowFileSystem: true,
770
+ timeoutMs: 30000,
771
+ });
772
+
773
+ return () => {
774
+ // Cleanup sandbox on unmount
775
+ void cleanupSandbox();
776
+ };
777
+ }, []);
778
+
779
+ //: Resilience (circuit breaker, rate limiter, fallback)
780
+ const [resilientProvider, setResilientProvider] = useState<ResilientProvider | null>(null);
781
+ useEffect(() => {
782
+ // Create resilient provider wrapper
783
+ // In production, this would wrap actual provider clients
784
+ const resilient = createResilientProvider(
785
+ [
786
+ {
787
+ id: provider,
788
+ name: provider,
789
+ priority: 0,
790
+ execute: async <T,>(request: () => Promise<T>) => request(),
791
+ },
792
+ ],
793
+ {
794
+ circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 30000 },
795
+ rateLimiter: { defaultBucket: { capacity: 60, refillRate: 1 } },
796
+ }
797
+ );
798
+
799
+ setResilientProvider(resilient);
800
+
801
+ return () => {
802
+ resilient.dispose();
803
+ };
804
+ }, [provider]);
805
+
806
+ //: Metrics integration
807
+ const metricsManager = useMemo<TuiMetricsManager>(() => getMetricsManager(), []);
808
+
809
+ // Track message processing
810
+ useEffect(() => {
811
+ if (messages.length > 0) {
812
+ metricsManager.recordMessage();
813
+ }
814
+ }, [messages.length, metricsManager]);
815
+
816
+ //: Enterprise integration
817
+ const [enterpriseHooks, setEnterpriseHooks] = useState<EnterpriseHooks | null>(null);
818
+ useEffect(() => {
819
+ let cancelled = false;
820
+
821
+ const loadEnterprise = async () => {
822
+ const result = await initializeEnterprise();
823
+ if (!cancelled && result.enabled) {
824
+ setEnterpriseHooks(createEnterpriseHooks());
825
+ console.debug("[enterprise] Enterprise mode active");
826
+ }
827
+ };
828
+
829
+ void loadEnterprise();
830
+
831
+ return () => {
832
+ cancelled = true;
833
+ void shutdownEnterprise();
834
+ };
835
+ }, []);
836
+
837
+ //: Wire enterprise hooks to ToolExecutor when both are available
838
+ useEffect(() => {
839
+ if (!enterpriseHooks) {
840
+ return;
841
+ }
842
+
843
+ // Get the tool executor from the agent loop
844
+ const toolExecutor = agentLoopProp?.getToolExecutor();
845
+ if (!toolExecutor) {
846
+ return;
847
+ }
848
+
849
+ // Wire the hooks using the adapter interface (EnterpriseToolCallInfo → ToolCallInfo)
850
+ const coreHooks: CoreEnterpriseHooks = {
851
+ onBeforeToolCall: async (tool: EnterpriseToolCallInfo) => {
852
+ return enterpriseHooks.onBeforeToolCall({
853
+ serverName: tool.serverName ?? "vellum",
854
+ toolName: tool.toolName,
855
+ arguments: tool.arguments,
856
+ });
857
+ },
858
+ onAfterToolCall: async (
859
+ tool: EnterpriseToolCallInfo,
860
+ result: unknown,
861
+ durationMs: number
862
+ ) => {
863
+ return enterpriseHooks.onAfterToolCall(
864
+ {
865
+ serverName: tool.serverName ?? "vellum",
866
+ toolName: tool.toolName,
867
+ arguments: tool.arguments,
868
+ },
869
+ result,
870
+ durationMs
871
+ );
872
+ },
873
+ };
874
+ toolExecutor.setEnterpriseHooks(coreHooks);
875
+
876
+ console.debug("[enterprise] Wired enterprise hooks to ToolExecutor");
877
+
878
+ return () => {
879
+ // Clear hooks on cleanup
880
+ toolExecutor.setEnterpriseHooks(null);
881
+ };
882
+ }, [enterpriseHooks, agentLoopProp]);
883
+
884
+ //: Tip engine integration
885
+ const { currentTip, showTip, dismissTip, tipsEnabled } = useTipEngine({
886
+ enabled: true,
887
+ maxTipsPerSession: 5,
888
+ tipIntervalMs: 60000,
889
+ });
890
+
891
+ // Note: The tip context useEffect is placed after state declarations below
892
+
893
+ // ==========================================================================
894
+ // Adapter Integration - Agent Adapter
895
+ // ==========================================================================
896
+
897
+ // Agent adapter for AgentLoop ↔ Context integration
898
+ // The hook connects AgentLoop events to MessagesContext and ToolsContext
899
+ const agentAdapter = useAgentAdapter({
900
+ clearOnDisconnect: false, // Preserve messages when disconnecting
901
+ });
902
+
903
+ // Destructure for stable references in useEffect dependency array.
904
+ // Even though agentAdapter is now memoized, this makes the dependency explicit
905
+ // and avoids re-running the effect if agentAdapter reference changes.
906
+ const { connect: adapterConnect, disconnect: adapterDisconnect } = agentAdapter;
907
+
908
+ // Connect to AgentLoop when provided
909
+ useEffect(() => {
910
+ if (agentLoopProp) {
911
+ adapterConnect(agentLoopProp);
912
+ // Wire up context management command
913
+ setCondenseCommandLoop(agentLoopProp);
914
+ // Thinking content is now handled directly in the agent-adapter
915
+ // and integrated into the streaming message's `thinking` field.
916
+ }
917
+ return () => {
918
+ adapterDisconnect();
919
+ // Clear context management command reference
920
+ setCondenseCommandLoop(null);
921
+ };
922
+ }, [agentLoopProp, adapterConnect, adapterDisconnect]);
923
+
924
+ const upsertTaskChainNode = useCallback(
925
+ (taskId: string, agentSlug: string | undefined, status: TaskChainNode["status"]) => {
926
+ setTaskChain((prev) => {
927
+ const now = new Date();
928
+ if (!prev) {
929
+ const rootNode: TaskChainNode = {
930
+ taskId,
931
+ parentTaskId: undefined,
932
+ agentSlug: agentSlug ?? "agent",
933
+ depth: 0,
934
+ createdAt: now,
935
+ status,
936
+ };
937
+
938
+ return {
939
+ chainId: `ui-${createId()}`,
940
+ rootTaskId: taskId,
941
+ nodes: new Map([[taskId, rootNode]]),
942
+ maxDepth: 0,
943
+ };
944
+ }
945
+
946
+ const nodes = new Map(prev.nodes);
947
+ const existing = nodes.get(taskId);
948
+ const node: TaskChainNode = {
949
+ taskId,
950
+ parentTaskId: existing?.parentTaskId,
951
+ agentSlug: agentSlug ?? existing?.agentSlug ?? "agent",
952
+ depth: existing?.depth ?? 0,
953
+ createdAt: existing?.createdAt ?? now,
954
+ status,
955
+ };
956
+
957
+ nodes.set(taskId, node);
958
+
959
+ return {
960
+ ...prev,
961
+ nodes,
962
+ rootTaskId: prev.rootTaskId ?? taskId,
963
+ maxDepth: prev.maxDepth ?? 0,
964
+ };
965
+ });
966
+ },
967
+ []
968
+ );
969
+
970
+ useEffect(() => {
971
+ if (!agentLoopProp) {
972
+ setTaskChain(null);
973
+ setCurrentTaskId(undefined);
974
+ return;
975
+ }
976
+
977
+ const handleDelegationStart = (delegationId: string, agent: string) => {
978
+ upsertTaskChainNode(delegationId, agent, "running");
979
+ setCurrentTaskId(delegationId);
980
+ };
981
+
982
+ const handleDelegationComplete = (delegationId: string) => {
983
+ upsertTaskChainNode(delegationId, undefined, "completed");
984
+ setCurrentTaskId((prev) => (prev === delegationId ? undefined : prev));
985
+ };
986
+
987
+ agentLoopProp.on("delegationStart", handleDelegationStart);
988
+ agentLoopProp.on("delegationComplete", handleDelegationComplete);
989
+
990
+ return () => {
991
+ agentLoopProp.off("delegationStart", handleDelegationStart);
992
+ agentLoopProp.off("delegationComplete", handleDelegationComplete);
993
+ };
994
+ }, [agentLoopProp, upsertTaskChainNode]);
995
+
996
+ useEffect(() => {
997
+ const orchestrator = agentLoopProp?.getConfig().orchestrator;
998
+ if (!orchestrator) {
999
+ return;
1000
+ }
1001
+
1002
+ const handleSpawned = (event: { data?: { taskId?: string; agentSlug?: string } }) => {
1003
+ if (!event.data?.taskId) return;
1004
+ upsertTaskChainNode(event.data.taskId, event.data.agentSlug, "running");
1005
+ setCurrentTaskId(event.data.taskId);
1006
+ };
1007
+
1008
+ const handleCompleted = (event: { data?: { taskId?: string; agentSlug?: string } }) => {
1009
+ if (!event.data?.taskId) return;
1010
+ upsertTaskChainNode(event.data.taskId, event.data.agentSlug, "completed");
1011
+ setCurrentTaskId((prev) => (prev === event.data?.taskId ? undefined : prev));
1012
+ };
1013
+
1014
+ const handleFailed = (event: { data?: { taskId?: string; agentSlug?: string } }) => {
1015
+ if (!event.data?.taskId) return;
1016
+ upsertTaskChainNode(event.data.taskId, event.data.agentSlug, "failed");
1017
+ setCurrentTaskId((prev) => (prev === event.data?.taskId ? undefined : prev));
1018
+ };
1019
+
1020
+ const handleStarted = (event: { data?: { taskId?: string; agentSlug?: string } }) => {
1021
+ if (!event.data?.taskId) return;
1022
+ upsertTaskChainNode(event.data.taskId, event.data.agentSlug, "running");
1023
+ setCurrentTaskId(event.data.taskId);
1024
+ };
1025
+
1026
+ orchestrator.on("subagent_spawned", handleSpawned);
1027
+ orchestrator.on("task_started", handleStarted);
1028
+ orchestrator.on("task_completed", handleCompleted);
1029
+ orchestrator.on("task_failed", handleFailed);
1030
+ orchestrator.on("subagent_cancelled", handleFailed);
1031
+
1032
+ return () => {
1033
+ orchestrator.off("subagent_spawned", handleSpawned);
1034
+ orchestrator.off("task_started", handleStarted);
1035
+ orchestrator.off("task_completed", handleCompleted);
1036
+ orchestrator.off("task_failed", handleFailed);
1037
+ orchestrator.off("subagent_cancelled", handleFailed);
1038
+ };
1039
+ }, [agentLoopProp, upsertTaskChainNode]);
1040
+
1041
+ // ==========================================================================
1042
+ // Core Services - Tools, Credentials, Sessions
1043
+ // ==========================================================================
1044
+
1045
+ const [credentialManager, setCredentialManager] = useState<CredentialManager | null>(null);
1046
+
1047
+ useEffect(() => {
1048
+ let cancelled = false;
1049
+
1050
+ const initializeCredentials = async () => {
1051
+ try {
1052
+ const manager = await createCredentialManager();
1053
+ if (!cancelled) {
1054
+ setCredentialManager(manager);
1055
+ }
1056
+ } catch (error) {
1057
+ console.warn(
1058
+ "[credentials] Failed to initialize credential manager:",
1059
+ error instanceof Error ? error.message : String(error)
1060
+ );
1061
+ }
1062
+ };
1063
+
1064
+ void initializeCredentials();
1065
+
1066
+ return () => {
1067
+ cancelled = true;
1068
+ };
1069
+ }, []);
1070
+
1071
+ const storageManagerRef = useRef<StorageManager | null>(null);
1072
+ const sessionListServiceRef = useRef<SessionListService | null>(null);
1073
+ const searchServiceRef = useRef<SearchService | null>(null);
1074
+ const sessionCacheRef = useRef<Session | null>(null);
1075
+ const [storageReady, setStorageReady] = useState(false);
1076
+
1077
+ useEffect(() => {
1078
+ let cancelled = false;
1079
+
1080
+ const initializeStorage = async () => {
1081
+ try {
1082
+ const manager = await StorageManager.create();
1083
+ const listService = new SessionListService(manager);
1084
+ const searchService = new SearchService(manager);
1085
+ await searchService.initialize();
1086
+
1087
+ if (!cancelled) {
1088
+ storageManagerRef.current = manager;
1089
+ sessionListServiceRef.current = listService;
1090
+ searchServiceRef.current = searchService;
1091
+ setStorageReady(true);
1092
+ }
1093
+ } catch (error) {
1094
+ if (!cancelled) {
1095
+ console.warn(
1096
+ "[sessions] Failed to initialize session storage:",
1097
+ error instanceof Error ? error.message : String(error)
1098
+ );
1099
+ }
1100
+ }
1101
+ };
1102
+
1103
+ void initializeStorage();
1104
+
1105
+ return () => {
1106
+ cancelled = true;
1107
+ };
1108
+ }, []);
1109
+
1110
+ // ==========================================================================
1111
+ // UI State Management
1112
+ // ==========================================================================
1113
+
1114
+ // Current coding mode state
1115
+ const [currentMode, setCurrentMode] = useState<CodingMode>(_mode);
1116
+ const modeManager = useMemo(
1117
+ () => createModeManager({ initialMode: _mode, requireSpecConfirmation: true }),
1118
+ [_mode]
1119
+ );
1120
+ const currentModeRef = useRef<CodingMode>(_mode);
1121
+
1122
+ // Modal visibility states
1123
+ const [showModeSelector, setShowModeSelector] = useState(false);
1124
+ const [showModelSelector, setShowModelSelector] = useState(false);
1125
+ const [showSessionManager, setShowSessionManager] = useState(false);
1126
+ const [showHelpModal, setShowHelpModal] = useState(false);
1127
+ const [showApprovalQueue, setShowApprovalQueue] = useState(false);
1128
+ const [checkpointDiff, setCheckpointDiff] = useState<{
1129
+ content: string;
1130
+ snapshotHash?: string;
1131
+ isLoading: boolean;
1132
+ isVisible: boolean;
1133
+ }>({ content: "", isLoading: false, isVisible: false });
1134
+
1135
+ // Onboarding state
1136
+ const [showOnboarding, setShowOnboarding] = useState(false);
1137
+ const [isFirstRun, setIsFirstRun] = useState(false);
1138
+ const [bannerSeen, setBannerSeenState] = useState(() => getBannerSeen());
1139
+ const [bannerSplashComplete, setBannerSplashComplete] = useState(false);
1140
+
1141
+ // Model selection state (moved earlier for onboarding config loading)
1142
+ const [currentModel, setCurrentModel] = useState(model);
1143
+ const [currentProvider, setCurrentProvider] = useState(provider);
1144
+
1145
+ // Agent task chain state for AgentProgress display
1146
+ const [taskChain, setTaskChain] = useState<TaskChain | null>(null);
1147
+ const [currentTaskId, setCurrentTaskId] = useState<string | undefined>(undefined);
1148
+
1149
+ useEffect(() => {
1150
+ currentModeRef.current = currentMode;
1151
+ }, [currentMode]);
1152
+
1153
+ useEffect(() => {
1154
+ setModeCommandsManager(modeManager);
1155
+ return () => setModeCommandsManager(null);
1156
+ }, [modeManager]);
1157
+
1158
+ const bannerOverride = banner ?? false;
1159
+ const shouldShowBanner = !showOnboarding && (bannerOverride || !bannerSeen);
1160
+ const bannerCycleDurationMs = 1600;
1161
+ const bannerUpdateIntervalMs = 16;
1162
+ const bannerCycles = 2;
1163
+ const bannerDisplayDurationMs = bannerCycleDurationMs * bannerCycles + 300;
1164
+
1165
+ const handleBannerComplete = useCallback(() => {
1166
+ setBannerSplashComplete(true);
1167
+ if (!bannerSeen) {
1168
+ saveBannerSeen(true);
1169
+ setBannerSeenState(true);
1170
+ }
1171
+ }, [bannerSeen]);
1172
+
1173
+ // Check onboarding completion status on mount and load saved config
1174
+ useEffect(() => {
1175
+ const checkOnboarding = async () => {
1176
+ const completed = await CoreOnboardingWizard.isCompleted();
1177
+ setIsFirstRun(!completed);
1178
+
1179
+ // Issue 2 Fix: Load saved config if onboarding was completed
1180
+ if (completed) {
1181
+ const wizard = new CoreOnboardingWizard();
1182
+ const loadResult = await wizard.loadState();
1183
+ if (loadResult.ok) {
1184
+ const config = wizard.generateConfig();
1185
+ if (config.provider && config.model) {
1186
+ setCurrentProvider(config.provider);
1187
+ setCurrentModel(config.model);
1188
+ }
1189
+ }
1190
+ }
1191
+ };
1192
+ void checkOnboarding();
1193
+ }, []);
1194
+
1195
+ // Spec mode phase tracking
1196
+ const [specPhase, setSpecPhase] = useState(1);
1197
+
1198
+ // Sidebar visibility states
1199
+ const [showSidebar, setShowSidebar] = useState(true);
1200
+ const [sidebarContent, setSidebarContent] = useState<SidebarContent>("memory");
1201
+
1202
+ // Warning ref for thinking mode (used for model capability warnings)
1203
+ const thinkingWarningRef = useRef<Set<string>>(new Set());
1204
+
1205
+ // ==========================================================================
1206
+ // FIX 2: Session Management - Connect Real Session Data
1207
+ // ==========================================================================
1208
+
1209
+ // Session list state - loaded from storage
1210
+ const [sessions, setSessions] = useState<SessionMetadata[]>([]);
1211
+ const [activeSessionId, setActiveSessionId] = useState<string>(() => createId());
1212
+
1213
+ // Extended token usage state (Fix 2: TUI layer token counting)
1214
+ const tokenUsageRef = useRef({
1215
+ inputTokens: 0,
1216
+ outputTokens: 0,
1217
+ thinkingTokens: 0,
1218
+ cacheReadTokens: 0,
1219
+ cacheWriteTokens: 0,
1220
+ totalCost: 0,
1221
+ });
1222
+ const [tokenUsage, setTokenUsage] = useState({
1223
+ inputTokens: 0,
1224
+ outputTokens: 0,
1225
+ thinkingTokens: 0,
1226
+ cacheReadTokens: 0,
1227
+ cacheWriteTokens: 0,
1228
+ totalCost: 0,
1229
+ });
1230
+ // Per-turn token usage for granular display
1231
+ const [turnUsage, setTurnUsage] = useState({
1232
+ inputTokens: 0,
1233
+ outputTokens: 0,
1234
+ thinkingTokens: 0,
1235
+ cacheReadTokens: 0,
1236
+ cacheWriteTokens: 0,
1237
+ });
1238
+ const previousTokenUsageRef = useRef({ inputTokens: 0, outputTokens: 0 });
1239
+
1240
+ const switchToSession = useCallback((sessionId: string, session?: Session) => {
1241
+ sessionCacheRef.current = session ?? null;
1242
+ previousTokenUsageRef.current = { inputTokens: 0, outputTokens: 0 };
1243
+ tokenUsageRef.current = {
1244
+ inputTokens: 0,
1245
+ outputTokens: 0,
1246
+ thinkingTokens: 0,
1247
+ cacheReadTokens: 0,
1248
+ cacheWriteTokens: 0,
1249
+ totalCost: 0,
1250
+ };
1251
+ setTokenUsage({
1252
+ inputTokens: 0,
1253
+ outputTokens: 0,
1254
+ thinkingTokens: 0,
1255
+ cacheReadTokens: 0,
1256
+ cacheWriteTokens: 0,
1257
+ totalCost: 0,
1258
+ });
1259
+ setTurnUsage({
1260
+ inputTokens: 0,
1261
+ outputTokens: 0,
1262
+ thinkingTokens: 0,
1263
+ cacheReadTokens: 0,
1264
+ cacheWriteTokens: 0,
1265
+ });
1266
+ setActiveSessionId(sessionId);
1267
+ }, []);
1268
+
1269
+ const refreshSessions = useCallback(async () => {
1270
+ const listService = sessionListServiceRef.current;
1271
+ if (!listService) {
1272
+ return;
1273
+ }
1274
+
1275
+ try {
1276
+ const recent = await listService.getRecentSessions(50);
1277
+ setSessions(
1278
+ recent.map((session) => ({
1279
+ id: session.id,
1280
+ title: session.title,
1281
+ timestamp: session.lastActive,
1282
+ messageCount: session.messageCount,
1283
+ lastMessage: session.summary,
1284
+ }))
1285
+ );
1286
+ } catch (error) {
1287
+ console.warn(
1288
+ "[sessions] Failed to refresh session list:",
1289
+ error instanceof Error ? error.message : String(error)
1290
+ );
1291
+ }
1292
+ }, []);
1293
+
1294
+ // Session storage (file-backed with memory fallback while initializing)
1295
+ const sessionStorage = useMemo<SessionStorage>(() => {
1296
+ const fallbackStorage = createMemorySessionStorage();
1297
+
1298
+ return {
1299
+ async save(sessionId, sessionMessages) {
1300
+ const storage = storageManagerRef.current;
1301
+ if (!storage) {
1302
+ await fallbackStorage.save(sessionId, sessionMessages);
1303
+ return;
1304
+ }
1305
+
1306
+ let session = sessionCacheRef.current;
1307
+ if (!session || session.metadata.id !== sessionId) {
1308
+ try {
1309
+ session = await storage.load(sessionId);
1310
+ } catch {
1311
+ session = createSession({
1312
+ id: sessionId,
1313
+ title: "New Session",
1314
+ mode: mapCodingModeToSessionMode(currentModeRef.current),
1315
+ workingDirectory: process.cwd(),
1316
+ messages: [],
1317
+ });
1318
+ }
1319
+ }
1320
+
1321
+ const title =
1322
+ sessionMessages.length > 0 ? buildSessionTitle(sessionMessages) : session.metadata.title;
1323
+ const summary =
1324
+ sessionMessages.length > 0
1325
+ ? buildSessionSummary(sessionMessages)
1326
+ : session.metadata.summary;
1327
+ const tokenCount = tokenUsageRef.current.inputTokens + tokenUsageRef.current.outputTokens;
1328
+
1329
+ const updatedSession = updateSessionMetadata(
1330
+ {
1331
+ ...session,
1332
+ messages: [...sessionMessages],
1333
+ },
1334
+ {
1335
+ title,
1336
+ summary,
1337
+ lastActive: new Date(),
1338
+ workingDirectory: process.cwd(),
1339
+ messageCount: sessionMessages.length,
1340
+ tokenCount,
1341
+ mode: mapCodingModeToSessionMode(currentModeRef.current),
1342
+ }
1343
+ );
1344
+
1345
+ sessionCacheRef.current = updatedSession;
1346
+ await storage.save(updatedSession);
1347
+ await refreshSessions();
1348
+ },
1349
+
1350
+ async load(sessionId) {
1351
+ const storage = storageManagerRef.current;
1352
+ if (!storage) {
1353
+ return fallbackStorage.load(sessionId);
1354
+ }
1355
+
1356
+ try {
1357
+ const session = await storage.load(sessionId);
1358
+ sessionCacheRef.current = session;
1359
+ return session.messages;
1360
+ } catch {
1361
+ return null;
1362
+ }
1363
+ },
1364
+
1365
+ async clear(sessionId) {
1366
+ const storage = storageManagerRef.current;
1367
+ if (!storage) {
1368
+ await fallbackStorage.clear(sessionId);
1369
+ return;
1370
+ }
1371
+
1372
+ try {
1373
+ await storage.delete(sessionId);
1374
+ if (sessionCacheRef.current?.metadata.id === sessionId) {
1375
+ sessionCacheRef.current = null;
1376
+ }
1377
+ await refreshSessions();
1378
+ } catch (error) {
1379
+ console.warn(
1380
+ "[sessions] Failed to clear session:",
1381
+ error instanceof Error ? error.message : String(error)
1382
+ );
1383
+ }
1384
+ },
1385
+ };
1386
+ }, [refreshSessions]);
1387
+
1388
+ // ==========================================================================
1389
+ // Adapter Integration - Session Adapter
1390
+ // ==========================================================================
1391
+
1392
+ // Session adapter for persistence with auto-save
1393
+ const {
1394
+ saveSession,
1395
+ clearSession,
1396
+ isSaving: _isSaving,
1397
+ isLoading: _isSessionLoading,
1398
+ error: sessionError,
1399
+ } = useSessionAdapter({
1400
+ sessionId: activeSessionId,
1401
+ storage: sessionStorage,
1402
+ autoSave: true,
1403
+ saveDebounceMs: 2000, // Auto-save after 2 seconds of inactivity
1404
+ autoLoad: true, // Load session on mount
1405
+ });
1406
+
1407
+ const costService = useMemo(
1408
+ () => createCostService({ sessionId: activeSessionId }),
1409
+ [activeSessionId]
1410
+ );
1411
+
1412
+ useEffect(() => {
1413
+ setCostCommandsService(costService);
1414
+ return () => setCostCommandsService(null);
1415
+ }, [costService]);
1416
+
1417
+ // Handle session errors
1418
+ useEffect(() => {
1419
+ if (sessionError) {
1420
+ console.error("Session error:", sessionError.message);
1421
+ notifyError(`Session error: ${sessionError.message}`);
1422
+ }
1423
+ }, [sessionError, notifyError]);
1424
+
1425
+ // Load sessions when storage is ready
1426
+ useEffect(() => {
1427
+ if (storageReady) {
1428
+ void refreshSessions();
1429
+ }
1430
+ }, [storageReady, refreshSessions]);
1431
+
1432
+ // ==========================================================================
1433
+ // Persistence Hook Integration
1434
+ // ==========================================================================
1435
+
1436
+ // Initialize persistence hook with advanced features
1437
+ const persistence = usePersistence({
1438
+ sessionId: activeSessionId,
1439
+ storage: sessionStorage,
1440
+ storageManager: storageManagerRef.current ?? undefined,
1441
+ enableAdvancedPersistence: !!storageManagerRef.current,
1442
+ autoSave: true,
1443
+ saveDebounceMs: 2000,
1444
+ autoLoad: true,
1445
+ onError: (error) => {
1446
+ console.error("[persistence] Error:", error.message);
1447
+ notifyError(`Persistence error: ${error.message}`);
1448
+ },
1449
+ onCheckpointCreated: (checkpointId) => {
1450
+ announce(`Checkpoint created: ${checkpointId.slice(0, 8)}`);
1451
+ },
1452
+ onRollbackComplete: (success) => {
1453
+ if (success) {
1454
+ announce("Rollback complete");
1455
+ } else {
1456
+ notifyError("Rollback failed");
1457
+ }
1458
+ },
1459
+ });
1460
+
1461
+ // Set persistence ref for slash commands
1462
+ useEffect(() => {
1463
+ setPersistenceRef({
1464
+ status: persistence.status,
1465
+ unsavedCount: persistence.unsavedCount,
1466
+ checkpoints: persistence.checkpoints,
1467
+ isAdvancedEnabled: persistence.isAdvancedEnabled,
1468
+ createCheckpoint: persistence.createCheckpoint,
1469
+ rollbackToCheckpoint: persistence.rollbackToCheckpoint,
1470
+ deleteCheckpoint: persistence.deleteCheckpoint,
1471
+ getMessagesToLose: persistence.getMessagesToLose,
1472
+ forceSave: persistence.forceSave,
1473
+ });
1474
+ return () => setPersistenceRef(null);
1475
+ }, [persistence]);
1476
+
1477
+ // Initialize persistence keyboard shortcuts
1478
+ usePersistenceShortcuts({
1479
+ persistence,
1480
+ enabled: true,
1481
+ onSave: () => announce("Session saved"),
1482
+ onCheckpointCreated: (id) => announce(`Checkpoint: ${id.slice(0, 8)}`),
1483
+ onError: (error) => notifyError(error),
1484
+ });
1485
+
1486
+ // ==========================================================================
1487
+ // FIX 4: Real Todo and Memory Data
1488
+ // ==========================================================================
1489
+
1490
+ const { executions, pendingApproval, approveExecution, rejectExecution, approveAll } = useTools();
1491
+ const pendingApprovalCountRef = useRef(pendingApproval.length);
1492
+
1493
+ useEffect(() => {
1494
+ const previousCount = pendingApprovalCountRef.current;
1495
+ const currentCount = pendingApproval.length;
1496
+ pendingApprovalCountRef.current = currentCount;
1497
+
1498
+ if (currentCount > 1 && previousCount <= 1) {
1499
+ setShowApprovalQueue(true);
1500
+ }
1501
+
1502
+ if (currentCount <= 1 && showApprovalQueue) {
1503
+ setShowApprovalQueue(false);
1504
+ }
1505
+ }, [pendingApproval.length, showApprovalQueue]);
1506
+
1507
+ const loadTodos = useCallback(async (): Promise<readonly TodoItemData[]> => {
1508
+ const todoFilePath = join(process.cwd(), ".vellum", "todos.json");
1509
+
1510
+ try {
1511
+ const content = await readFile(todoFilePath, { encoding: "utf-8" });
1512
+ const parsed = JSON.parse(content) as unknown;
1513
+
1514
+ if (!parsed || typeof parsed !== "object") {
1515
+ return [];
1516
+ }
1517
+
1518
+ const items = (parsed as { items?: unknown }).items;
1519
+ if (!Array.isArray(items)) {
1520
+ return [];
1521
+ }
1522
+
1523
+ return items
1524
+ .map((item): TodoItemData | null => {
1525
+ if (!item || typeof item !== "object") return null;
1526
+
1527
+ const id = (item as { id?: unknown }).id;
1528
+ const text = (item as { text?: unknown }).text;
1529
+ const completed = (item as { completed?: unknown }).completed;
1530
+ const createdAt = (item as { createdAt?: unknown }).createdAt;
1531
+ const completedAt = (item as { completedAt?: unknown }).completedAt;
1532
+
1533
+ if (
1534
+ typeof id !== "number" ||
1535
+ typeof text !== "string" ||
1536
+ typeof completed !== "boolean"
1537
+ ) {
1538
+ return null;
1539
+ }
1540
+
1541
+ const createdAtStr = typeof createdAt === "string" ? createdAt : new Date().toISOString();
1542
+ const completedAtStr = typeof completedAt === "string" ? completedAt : undefined;
1543
+
1544
+ const mapped: TodoItemData = {
1545
+ id,
1546
+ title: text,
1547
+ status: completed ? "completed" : "pending",
1548
+ createdAt: createdAtStr,
1549
+ completedAt: completedAtStr,
1550
+ };
1551
+
1552
+ return mapped;
1553
+ })
1554
+ .filter((item): item is TodoItemData => item !== null);
1555
+ } catch {
1556
+ return [];
1557
+ }
1558
+ }, []);
1559
+
1560
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex memory loading from multiple sources
1561
+ const loadMemories = useCallback(async (): Promise<MemoryPanelProps["entries"]> => {
1562
+ const projectEntries: Array<MemoryPanelProps["entries"][number]> = [];
1563
+
1564
+ // 1) Project memory service entries (.vellum/memory.json)
1565
+ try {
1566
+ const service = new ProjectMemoryService();
1567
+ await service.initialize(process.cwd());
1568
+ try {
1569
+ projectEntries.push(...(await service.listEntries()));
1570
+ } finally {
1571
+ await service.close();
1572
+ }
1573
+ } catch {
1574
+ // Best-effort: ignore load failures and fall back to other sources.
1575
+ }
1576
+
1577
+ // 2) save_memory tool entries (.vellum/memory/{namespace}/{key}.json)
1578
+ const toolEntries: Array<MemoryPanelProps["entries"][number]> = [];
1579
+ const toolMemoryBaseDir = join(process.cwd(), ".vellum", "memory");
1580
+
1581
+ try {
1582
+ const namespaceEntries = await readdir(toolMemoryBaseDir, { withFileTypes: true });
1583
+
1584
+ for (const namespaceDirent of namespaceEntries) {
1585
+ if (!namespaceDirent.isDirectory()) continue;
1586
+ const namespace = namespaceDirent.name;
1587
+ const namespaceDir = join(toolMemoryBaseDir, namespace);
1588
+
1589
+ const files = await readdir(namespaceDir, { withFileTypes: true });
1590
+ for (const file of files) {
1591
+ if (!file.isFile()) continue;
1592
+ if (!file.name.endsWith(".json")) continue;
1593
+
1594
+ const memoryFilePath = join(namespaceDir, file.name);
1595
+
1596
+ try {
1597
+ const content = await readFile(memoryFilePath, { encoding: "utf-8" });
1598
+ const parsed = JSON.parse(content) as unknown;
1599
+
1600
+ if (!parsed || typeof parsed !== "object") continue;
1601
+
1602
+ const value = (parsed as { value?: unknown }).value;
1603
+ const storedAt = (parsed as { storedAt?: unknown }).storedAt;
1604
+ const updatedAt = (parsed as { updatedAt?: unknown }).updatedAt;
1605
+ const key = (parsed as { key?: unknown }).key;
1606
+
1607
+ if (typeof value !== "string" || typeof key !== "string") continue;
1608
+
1609
+ const createdAtDate = typeof storedAt === "string" ? new Date(storedAt) : new Date();
1610
+ const updatedAtDate =
1611
+ typeof updatedAt === "string" ? new Date(updatedAt) : createdAtDate;
1612
+
1613
+ toolEntries.push({
1614
+ key: `${namespace}/${key}`,
1615
+ type: "context",
1616
+ content: value,
1617
+ createdAt: createdAtDate,
1618
+ updatedAt: updatedAtDate,
1619
+ metadata: {
1620
+ tags: ["tool:save_memory", `namespace:${namespace}`],
1621
+ importance: 0.5,
1622
+ },
1623
+ });
1624
+ } catch {
1625
+ // Skip unreadable/invalid entries.
1626
+ }
1627
+ }
1628
+ }
1629
+ } catch {
1630
+ // No tool memory directory (or unreadable). Ignore.
1631
+ }
1632
+
1633
+ const combined: Array<MemoryPanelProps["entries"][number]> = [
1634
+ ...projectEntries,
1635
+ ...toolEntries,
1636
+ ];
1637
+ combined.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
1638
+ return combined;
1639
+ }, []);
1640
+
1641
+ const { todoItems, memoryEntries, refreshTodos } = useSidebarPanelData({
1642
+ sidebarVisible: showSidebar,
1643
+ sidebarContent,
1644
+ executions,
1645
+ loadTodos,
1646
+ loadMemories,
1647
+ });
1648
+
1649
+ const [thinkingModeEnabled, setThinkingModeEnabled] = useState(() => getThinkingState().enabled);
1650
+
1651
+ // Subscribe to global thinking state changes (mode toggle via /think)
1652
+ useEffect(() => {
1653
+ const unsubscribe = subscribeToThinkingState((state) => {
1654
+ setThinkingModeEnabled(state.enabled);
1655
+ });
1656
+ return unsubscribe;
1657
+ }, []);
1658
+
1659
+ const effectiveApprovalPolicy = useMemo<ApprovalPolicy>(() => {
1660
+ if (_approval) {
1661
+ return _approval;
1662
+ }
1663
+ // Fall back to mode-specific defaults.
1664
+ // Using explicit mapping avoids assumptions about ModeManager's internal config.
1665
+ return getDefaultApprovalPolicyForMode(currentMode);
1666
+ }, [_approval, currentMode]);
1667
+
1668
+ const trustMode = useMemo<TrustMode>(
1669
+ () => approvalPolicyToTrustMode(effectiveApprovalPolicy),
1670
+ [effectiveApprovalPolicy]
1671
+ );
1672
+
1673
+ // ==========================================================================
1674
+ // Provider Status (ModelStatusBar integration)
1675
+ // ==========================================================================
1676
+
1677
+ // Track provider health status with circuit breaker states
1678
+ const providerStatus = useProviderStatus({
1679
+ initialProviders: [{ id: provider, name: provider, isActive: true }],
1680
+ });
1681
+
1682
+ // ==========================================================================
1683
+ // Snapshots (SnapshotCheckpointPanel integration)
1684
+ // ==========================================================================
1685
+
1686
+ const snapshots = useSnapshots();
1687
+
1688
+ const openCheckpointDiff = useCallback(
1689
+ async (hash: string) => {
1690
+ setCheckpointDiff({ content: "", snapshotHash: hash, isLoading: true, isVisible: true });
1691
+
1692
+ try {
1693
+ const diff = await snapshots.diff(hash);
1694
+ setCheckpointDiff({
1695
+ content: diff,
1696
+ snapshotHash: hash,
1697
+ isLoading: false,
1698
+ isVisible: true,
1699
+ });
1700
+ } catch (error) {
1701
+ setCheckpointDiff({
1702
+ content: `Failed to load diff: ${error instanceof Error ? error.message : String(error)}`,
1703
+ snapshotHash: hash,
1704
+ isLoading: false,
1705
+ isVisible: true,
1706
+ });
1707
+ }
1708
+ },
1709
+ [snapshots]
1710
+ );
1711
+
1712
+ const closeCheckpointDiff = useCallback(() => {
1713
+ setCheckpointDiff((prev) => ({ ...prev, isVisible: false }));
1714
+ }, []);
1715
+
1716
+ // Cost tracking state for CostWarning component
1717
+ const [costWarningState, setCostWarningState] = useState<{
1718
+ show: boolean;
1719
+ limitReached: boolean;
1720
+ percentUsed: number;
1721
+ costLimit: number;
1722
+ requestLimit: number;
1723
+ }>({ show: false, limitReached: false, percentUsed: 0, costLimit: 10, requestLimit: 100 });
1724
+
1725
+ // Auto-approval status state for AutoApprovalStatus component
1726
+ // NOTE: setAutoApprovalState is kept for future integration with AgentLoop's
1727
+ // getAutoApprovalStatus() method. Currently set via useEffect below.
1728
+ const [autoApprovalState, setAutoApprovalState] = useState<{
1729
+ consecutiveRequests: number;
1730
+ requestLimit: number;
1731
+ consecutiveCost: number;
1732
+ costLimit: number;
1733
+ requestPercentUsed: number;
1734
+ costPercentUsed: number;
1735
+ limitReached: boolean;
1736
+ limitType?: "requests" | "cost";
1737
+ } | null>(null);
1738
+
1739
+ // Update auto-approval state from AgentLoop when available
1740
+ useEffect(() => {
1741
+ if (!agentLoopProp) return;
1742
+
1743
+ // Check if AgentLoop has getAutoApprovalStatus method (Phase 35+)
1744
+ const loopWithStatus = agentLoopProp as typeof agentLoopProp & {
1745
+ getAutoApprovalStatus?: () => {
1746
+ consecutiveRequests: number;
1747
+ requestLimit: number;
1748
+ consecutiveCost: number;
1749
+ costLimit: number;
1750
+ requestPercentUsed: number;
1751
+ costPercentUsed: number;
1752
+ requestLimitReached: boolean;
1753
+ costLimitReached: boolean;
1754
+ } | null;
1755
+ };
1756
+
1757
+ if (typeof loopWithStatus.getAutoApprovalStatus !== "function") {
1758
+ return;
1759
+ }
1760
+
1761
+ // Periodic polling for auto-approval status
1762
+ const updateStatus = () => {
1763
+ const status = loopWithStatus.getAutoApprovalStatus?.();
1764
+ if (status) {
1765
+ setAutoApprovalState({
1766
+ consecutiveRequests: status.consecutiveRequests,
1767
+ requestLimit: status.requestLimit,
1768
+ consecutiveCost: status.consecutiveCost,
1769
+ costLimit: status.costLimit,
1770
+ requestPercentUsed: status.requestPercentUsed,
1771
+ costPercentUsed: status.costPercentUsed,
1772
+ limitReached: status.requestLimitReached || status.costLimitReached,
1773
+ limitType: status.requestLimitReached
1774
+ ? "requests"
1775
+ : status.costLimitReached
1776
+ ? "cost"
1777
+ : undefined,
1778
+ });
1779
+ }
1780
+ };
1781
+
1782
+ // Update immediately and then on interval
1783
+ updateStatus();
1784
+ const interval = setInterval(updateStatus, 1000);
1785
+ return () => clearInterval(interval);
1786
+ }, [agentLoopProp]);
1787
+
1788
+ // Update cost warning based on token usage
1789
+ useEffect(() => {
1790
+ const costLimit = 10; // Default $10 limit
1791
+ const requestLimit = 100;
1792
+ const percentUsed = costLimit > 0 ? (tokenUsage.totalCost / costLimit) * 100 : 0;
1793
+ const showWarning = percentUsed >= 80; // Show when 80%+ of limit used
1794
+ const limitReached = percentUsed >= 100;
1795
+
1796
+ setCostWarningState({
1797
+ show: showWarning,
1798
+ limitReached,
1799
+ percentUsed: Math.min(percentUsed, 100),
1800
+ costLimit,
1801
+ requestLimit,
1802
+ });
1803
+ }, [tokenUsage.totalCost]);
1804
+
1805
+ // ==========================================================================
1806
+ // FIX 3: Permission System Integration
1807
+ // ==========================================================================
1808
+
1809
+ // Drive tool approvals from ToolsContext (source of truth) and resume the AgentLoop
1810
+ // by calling grantPermission()/denyPermission() when the user decides.
1811
+ const { activeApproval, activeRiskLevel, approveActive, rejectActive } =
1812
+ useToolApprovalController({ agentLoop: agentLoopProp });
1813
+
1814
+ const hasActiveApproval = activeApproval !== null;
1815
+
1816
+ // Update banner state
1817
+ const [updateAvailable, setUpdateAvailable] = useState<{
1818
+ current: string;
1819
+ latest: string;
1820
+ } | null>(null);
1821
+
1822
+ // ==========================================================================
1823
+ // FIX 5: Mode & Theme Persistence
1824
+ // ==========================================================================
1825
+
1826
+ // Load persisted mode and theme on mount
1827
+ useEffect(() => {
1828
+ // Note: In terminal environment, we use process.env or config files
1829
+ // For now, we can use a simple in-memory approach or file-based config
1830
+ // This demonstrates the persistence pattern
1831
+ const savedMode = process.env.VELLUM_MODE as CodingMode | undefined;
1832
+ if (savedMode && ["vibe", "plan", "spec"].includes(savedMode)) {
1833
+ setCurrentMode(savedMode);
1834
+ }
1835
+ }, []);
1836
+
1837
+ // Show onboarding wizard on first run
1838
+ useEffect(() => {
1839
+ if (isFirstRun) {
1840
+ setShowOnboarding(true);
1841
+ }
1842
+ }, [isFirstRun]);
1843
+
1844
+ //: Show contextual tips based on state (placed after state declarations)
1845
+ useEffect(() => {
1846
+ if (!tipsEnabled) return;
1847
+
1848
+ const context = buildTipContext({
1849
+ screen: showOnboarding ? "onboarding" : "main",
1850
+ mode: currentMode,
1851
+ featuresUsedCount: messages.length,
1852
+ });
1853
+
1854
+ showTip(context);
1855
+ }, [currentMode, messages.length, showOnboarding, tipsEnabled, showTip]);
1856
+
1857
+ // Ref to track current cancellation controller
1858
+ const cancellationRef = useRef<CancellationController | null>(null);
1859
+
1860
+ // ==========================================================================
1861
+ // Hooks Integration
1862
+ // ==========================================================================
1863
+
1864
+ // Screen reader accessibility hook
1865
+ const { announce } = useScreenReader({
1866
+ verbose: false,
1867
+ });
1868
+
1869
+ // Input history hook for up/down arrow navigation
1870
+ const { addToHistory } = useInputHistory({
1871
+ maxItems: 100,
1872
+ persistKey: "vellum-command-history",
1873
+ });
1874
+
1875
+ // Backtrack sync helpers
1876
+ // - suppressBacktrackPushRef prevents the message->backtrack sync effect from creating new
1877
+ // history snapshots when we are *restoring* state (undo/redo/branch switch).
1878
+ // - lastMessageCountRef tracks the last "real" message count that we recorded into backtrack.
1879
+ const suppressBacktrackPushRef = useRef(false);
1880
+ const lastMessageCountRef = useRef(0);
1881
+
1882
+ const applyBacktrackMessages = useCallback(
1883
+ (nextMessages: Message[], announcement?: string) => {
1884
+ suppressBacktrackPushRef.current = true;
1885
+ lastMessageCountRef.current = nextMessages.length;
1886
+ setMessages([...nextMessages]);
1887
+ if (announcement) {
1888
+ announce(announcement);
1889
+ }
1890
+ },
1891
+ [announce, setMessages]
1892
+ );
1893
+
1894
+ // Backtrack hook for undo/redo conversation state
1895
+ const {
1896
+ backtrackState,
1897
+ branches,
1898
+ push: pushBacktrack,
1899
+ undo: undoBacktrack,
1900
+ redo: redoBacktrack,
1901
+ createBranch: createBacktrackBranch,
1902
+ switchBranch: switchBacktrackBranch,
1903
+ } = useBacktrack({
1904
+ initialState: { messages: [] as Message[] },
1905
+ maxHistory: 50,
1906
+ enableBranching: true,
1907
+ onStateChange: (state, action) => {
1908
+ if (action === "undo" || action === "redo") {
1909
+ applyBacktrackMessages(
1910
+ state.messages,
1911
+ `${action === "undo" ? "Undid" : "Redid"} last message`
1912
+ );
1913
+ }
1914
+ },
1915
+ });
1916
+
1917
+ const handleCreateBacktrackBranch = useCallback(() => {
1918
+ // Match useBacktrack's default naming behavior: `Branch ${Object.keys(state.branches).length}`
1919
+ const branchName = `Branch ${branches.length}`;
1920
+ createBacktrackBranch(branchName);
1921
+ notifyTaskComplete(`Created branch: ${branchName}`);
1922
+ announce(`Created branch: ${branchName}`);
1923
+ }, [announce, branches.length, createBacktrackBranch, notifyTaskComplete]);
1924
+
1925
+ const handleSwitchBacktrackBranch = useCallback(
1926
+ (branchId: string) => {
1927
+ const targetBranch = branches.find((b) => b.id === branchId);
1928
+ if (!targetBranch) {
1929
+ notifyError("Branch not found");
1930
+ return;
1931
+ }
1932
+
1933
+ // Update the underlying backtrack state first.
1934
+ switchBacktrackBranch(branchId);
1935
+
1936
+ // Then apply the target branch's latest snapshot to the messages view.
1937
+ const latestSnapshot = targetBranch.history.at(-1);
1938
+ const latestState = latestSnapshot?.state as { messages?: Message[] } | undefined;
1939
+ const nextMessages = latestState?.messages;
1940
+ if (Array.isArray(nextMessages)) {
1941
+ applyBacktrackMessages(nextMessages);
1942
+ }
1943
+
1944
+ notifyTaskComplete(`Switched to branch: ${targetBranch.name}`);
1945
+ announce(`Switched to branch: ${targetBranch.name}`);
1946
+ },
1947
+ [
1948
+ announce,
1949
+ applyBacktrackMessages,
1950
+ branches,
1951
+ notifyError,
1952
+ notifyTaskComplete,
1953
+ switchBacktrackBranch,
1954
+ ]
1955
+ );
1956
+
1957
+ // Sync messages with backtrack state
1958
+ // Use a ref to track last message count to avoid unnecessary pushBacktrack calls
1959
+ useEffect(() => {
1960
+ if (suppressBacktrackPushRef.current) {
1961
+ suppressBacktrackPushRef.current = false;
1962
+ return;
1963
+ }
1964
+
1965
+ // Only push backtrack when messages are actually added (not undo/redo/branch restores).
1966
+ if (messages.length > lastMessageCountRef.current) {
1967
+ lastMessageCountRef.current = messages.length;
1968
+ pushBacktrack({ messages: [...messages] }, "Message added");
1969
+ }
1970
+ }, [messages, pushBacktrack]);
1971
+
1972
+ // Mode shortcuts hook (Alt+1/2/3)
1973
+ useModeShortcuts({
1974
+ modeManager,
1975
+ enabled:
1976
+ !showModeSelector &&
1977
+ !showModelSelector &&
1978
+ !hasActiveApproval &&
1979
+ !showSessionManager &&
1980
+ !showOnboarding &&
1981
+ !interactivePrompt &&
1982
+ !followupPrompt &&
1983
+ !pendingOperation,
1984
+ onModeSwitch: (mode, success) => {
1985
+ if (success) {
1986
+ setCurrentMode(mode);
1987
+ announce(`Switched to ${mode} mode`);
1988
+ }
1989
+ },
1990
+ onError: (mode, error) => {
1991
+ if (modeManager.isPendingSpecConfirmation()) {
1992
+ return;
1993
+ }
1994
+ announce(`Failed to switch to ${mode}: ${error}`);
1995
+ },
1996
+ });
1997
+
1998
+ const openSpecConfirmation = useCallback(() => {
1999
+ if (interactivePrompt || pendingOperation) {
2000
+ return;
2001
+ }
2002
+
2003
+ setPromptValue("");
2004
+ setInteractivePrompt({
2005
+ inputType: "confirm",
2006
+ message: "⚠️ Switch to spec mode? This enables a 6-phase structured workflow.",
2007
+ defaultValue: "n",
2008
+ handler: async (value: string): Promise<CommandResult> => {
2009
+ const confirmed = value.toLowerCase() === "y" || value.toLowerCase() === "yes";
2010
+ if (!confirmed) {
2011
+ modeManager.cancelSpecSwitch();
2012
+ return { kind: "success", message: "Mode switch cancelled." };
2013
+ }
2014
+
2015
+ const result = await modeManager.confirmSpecMode();
2016
+ if (result.success) {
2017
+ return { kind: "success", message: "📐 Switched to spec mode." };
2018
+ }
2019
+
2020
+ return {
2021
+ kind: "error",
2022
+ code: "OPERATION_NOT_ALLOWED",
2023
+ message: result.reason ?? "Unable to switch to spec mode.",
2024
+ };
2025
+ },
2026
+ onCancel: () => {
2027
+ modeManager.cancelSpecSwitch();
2028
+ return { kind: "success", message: "Mode switch cancelled." };
2029
+ },
2030
+ });
2031
+ }, [interactivePrompt, pendingOperation, modeManager]);
2032
+
2033
+ useEffect(() => {
2034
+ const handleModeChanged = (event: { currentMode: CodingMode }) => {
2035
+ setCurrentMode(event.currentMode);
2036
+ };
2037
+
2038
+ const handleSpecRequired = () => {
2039
+ openSpecConfirmation();
2040
+ };
2041
+
2042
+ modeManager.on("mode-changed", handleModeChanged);
2043
+ modeManager.on("spec-confirmation-required", handleSpecRequired);
2044
+
2045
+ return () => {
2046
+ modeManager.off("mode-changed", handleModeChanged);
2047
+ modeManager.off("spec-confirmation-required", handleSpecRequired);
2048
+ };
2049
+ }, [modeManager, openSpecConfirmation]);
2050
+
2051
+ // Hotkeys hook for global keyboard shortcuts
2052
+ const hotkeyDefinitions: HotkeyDefinition[] = useMemo(() => {
2053
+ const hotkeys: HotkeyDefinition[] = [
2054
+ {
2055
+ key: "m",
2056
+ alt: true,
2057
+ handler: () => setShowModeSelector((prev) => !prev),
2058
+ description: "Toggle mode selector",
2059
+ scope: "global",
2060
+ },
2061
+ {
2062
+ key: "k",
2063
+ alt: true,
2064
+ handler: () => setShowSidebar((prev) => !prev),
2065
+ description: "Toggle sidebar",
2066
+ scope: "global",
2067
+ },
2068
+ {
2069
+ key: "t",
2070
+ ctrl: true,
2071
+ handler: () => {
2072
+ const newState = toggleThinking();
2073
+ announce(newState ? "Thinking mode enabled" : "Thinking mode disabled");
2074
+ },
2075
+ description: "Toggle thinking mode",
2076
+ scope: "global",
2077
+ },
2078
+ // Alt+T alternative for todo panel (thinking uses Ctrl+T)
2079
+ {
2080
+ key: "t",
2081
+ alt: true,
2082
+ handler: () => {
2083
+ setShowSidebar(true);
2084
+ setSidebarContent("todo");
2085
+ },
2086
+ description: "Show todo panel (Alt)",
2087
+ scope: "global",
2088
+ },
2089
+ {
2090
+ key: "p",
2091
+ alt: true,
2092
+ handler: () => {
2093
+ setShowSidebar(true);
2094
+ setSidebarContent("memory");
2095
+ },
2096
+ description: "Show memory panel",
2097
+ scope: "global",
2098
+ },
2099
+ {
2100
+ key: "g",
2101
+ alt: true,
2102
+ handler: () => {
2103
+ setShowSidebar(true);
2104
+ setSidebarContent("tools");
2105
+ },
2106
+ description: "Show tools panel",
2107
+ scope: "global",
2108
+ },
2109
+ {
2110
+ key: "o",
2111
+ alt: true,
2112
+ handler: () => {
2113
+ setShowSidebar(true);
2114
+ setSidebarContent("mcp");
2115
+ },
2116
+ description: "Show MCP panel",
2117
+ scope: "global",
2118
+ },
2119
+ // Snapshots panel (Alt+S)
2120
+ {
2121
+ key: "s",
2122
+ alt: true,
2123
+ handler: () => {
2124
+ setShowSidebar(true);
2125
+ setSidebarContent("snapshots");
2126
+ },
2127
+ description: "Show snapshots panel",
2128
+ scope: "global",
2129
+ },
2130
+ {
2131
+ key: "s",
2132
+ ctrl: true,
2133
+ handler: () => setShowSessionManager((prev) => !prev),
2134
+ description: "Session manager",
2135
+ scope: "global",
2136
+ },
2137
+ {
2138
+ key: "f1",
2139
+ handler: () => setShowHelpModal(true),
2140
+ description: "Show help",
2141
+ scope: "global",
2142
+ },
2143
+ {
2144
+ key: "?",
2145
+ shift: true,
2146
+ handler: () => setShowHelpModal(true),
2147
+ description: "Show help",
2148
+ scope: "global",
2149
+ },
2150
+ {
2151
+ key: "a",
2152
+ ctrl: true,
2153
+ shift: true,
2154
+ handler: () => setShowApprovalQueue((prev) => !prev),
2155
+ description: "Toggle approval queue",
2156
+ scope: "global",
2157
+ },
2158
+ {
2159
+ key: "z",
2160
+ ctrl: true,
2161
+ handler: () => {
2162
+ if (backtrackState.canUndo) {
2163
+ undoBacktrack();
2164
+ }
2165
+ },
2166
+ description: "Undo",
2167
+ scope: "global",
2168
+ },
2169
+ {
2170
+ key: "y",
2171
+ ctrl: true,
2172
+ handler: () => {
2173
+ if (backtrackState.canRedo) {
2174
+ redoBacktrack();
2175
+ }
2176
+ },
2177
+ description: "Redo",
2178
+ scope: "global",
2179
+ },
2180
+ // Model selector toggle (Alt+Shift+M)
2181
+ {
2182
+ key: "m",
2183
+ alt: true,
2184
+ shift: true,
2185
+ handler: () => {
2186
+ setShowModelSelector((prev) => !prev);
2187
+ announce(showModelSelector ? "Model selector closed" : "Model selector opened");
2188
+ },
2189
+ description: "Toggle model selector",
2190
+ scope: "global",
2191
+ },
2192
+ // Vim mode toggle
2193
+ {
2194
+ key: "v",
2195
+ ctrl: true,
2196
+ handler: () => {
2197
+ setVimEnabled((prev) => !prev);
2198
+ vim.toggle();
2199
+ announce(vimEnabled ? "Vim mode disabled" : "Vim mode enabled");
2200
+ },
2201
+ description: "Toggle vim mode",
2202
+ scope: "global",
2203
+ },
2204
+ // Copy mode toggle
2205
+ {
2206
+ key: "c",
2207
+ ctrl: true,
2208
+ shift: true,
2209
+ handler: () => {
2210
+ if (copyMode.state.active) {
2211
+ copyMode.exitCopyMode();
2212
+ announce("Copy mode exited");
2213
+ } else {
2214
+ copyMode.enterCopyMode();
2215
+ announce("Copy mode entered - use arrow keys to select");
2216
+ }
2217
+ },
2218
+ description: "Toggle copy mode",
2219
+ scope: "global",
2220
+ },
2221
+ ];
2222
+
2223
+ // Alternate buffer toggle for full-screen views
2224
+ if (alternateBufferEnabled) {
2225
+ hotkeys.push({
2226
+ key: "f",
2227
+ ctrl: true,
2228
+ handler: () => {
2229
+ alternateBuffer.toggle();
2230
+ announce(alternateBuffer.isAlternate ? "Exited fullscreen" : "Entered fullscreen");
2231
+ },
2232
+ description: "Toggle fullscreen mode",
2233
+ scope: "global",
2234
+ });
2235
+ }
2236
+
2237
+ return hotkeys;
2238
+ }, [
2239
+ backtrackState.canUndo,
2240
+ backtrackState.canRedo,
2241
+ undoBacktrack,
2242
+ redoBacktrack,
2243
+ announce,
2244
+ vimEnabled,
2245
+ vim,
2246
+ copyMode,
2247
+ alternateBuffer,
2248
+ alternateBufferEnabled,
2249
+ showModelSelector,
2250
+ ]);
2251
+
2252
+ useHotkeys(hotkeyDefinitions, {
2253
+ enabled:
2254
+ !showModeSelector &&
2255
+ !showModelSelector &&
2256
+ !hasActiveApproval &&
2257
+ !showSessionManager &&
2258
+ !showHelpModal &&
2259
+ !showOnboarding &&
2260
+ !interactivePrompt &&
2261
+ !followupPrompt &&
2262
+ !pendingOperation,
2263
+ });
2264
+
2265
+ //: Wire theme context to theme commands
2266
+ useEffect(() => {
2267
+ setThemeContext(themeContext);
2268
+ return () => setThemeContext(null);
2269
+ }, [themeContext]);
2270
+
2271
+ //: Initialize command registry once on mount
2272
+ const [commandRegistryVersion, setCommandRegistryVersion] = useState(0);
2273
+ const bumpCommandRegistryVersion = useCallback(
2274
+ () => setCommandRegistryVersion((prev) => prev + 1),
2275
+ []
2276
+ );
2277
+ const commandRegistry = useMemo(() => createCommandRegistry(), []);
2278
+
2279
+ // ==========================================================================
2280
+ //: Plugin System Integration
2281
+ // ==========================================================================
2282
+
2283
+ // Plugin initialization state
2284
+ // Note: pluginResult can be used for status display (plugin count, errors)
2285
+ // Note: pluginsLoading can be used for loading indicator
2286
+ const [_pluginResult, setPluginResult] = useState<PluginInitResult | null>(null);
2287
+ const [_pluginsLoading, setPluginsLoading] = useState(true);
2288
+
2289
+ // Initialize plugins on mount
2290
+ useEffect(() => {
2291
+ let cancelled = false;
2292
+
2293
+ const loadPlugins = async () => {
2294
+ try {
2295
+ const result = await initializePlugins({
2296
+ projectRoot: process.cwd(),
2297
+ autoTrust: false,
2298
+ eagerLoad: false,
2299
+ includeBuiltin: true,
2300
+ includeUser: true,
2301
+ includeGlobal: true,
2302
+ });
2303
+
2304
+ if (!cancelled) {
2305
+ setPluginResult(result);
2306
+
2307
+ // Register plugin commands into the registry
2308
+ registerPluginCommands(commandRegistry, result);
2309
+ bumpCommandRegistryVersion();
2310
+
2311
+ // Log plugin loading results
2312
+ if (result.errors.length > 0) {
2313
+ console.warn(
2314
+ `[plugins] Loaded ${result.pluginCount} plugins with ${result.errors.length} errors`
2315
+ );
2316
+ }
2317
+
2318
+ setPluginsLoading(false);
2319
+ }
2320
+ } catch (error) {
2321
+ if (!cancelled) {
2322
+ console.error("[plugins] Failed to initialize plugins:", error);
2323
+ setPluginsLoading(false);
2324
+ }
2325
+ }
2326
+ };
2327
+
2328
+ void loadPlugins();
2329
+
2330
+ return () => {
2331
+ cancelled = true;
2332
+ disposePlugins();
2333
+ };
2334
+ }, [commandRegistry, bumpCommandRegistryVersion]);
2335
+
2336
+ // Load user-defined commands from ~/.vellum/commands
2337
+ useEffect(() => {
2338
+ let cancelled = false;
2339
+
2340
+ const loadUserCommands = async () => {
2341
+ try {
2342
+ const result = await registerUserCommands(commandRegistry);
2343
+ if (cancelled) return;
2344
+
2345
+ if (result.commands.length > 0) {
2346
+ bumpCommandRegistryVersion();
2347
+ }
2348
+ } catch (error) {
2349
+ if (!cancelled) {
2350
+ console.warn(
2351
+ "[user-commands] Failed to load commands:",
2352
+ error instanceof Error ? error.message : String(error)
2353
+ );
2354
+ }
2355
+ }
2356
+ };
2357
+
2358
+ void loadUserCommands();
2359
+
2360
+ return () => {
2361
+ cancelled = true;
2362
+ };
2363
+ }, [commandRegistry, bumpCommandRegistryVersion]);
2364
+
2365
+ // ==========================================================================
2366
+ //: LSP Integration
2367
+ // ==========================================================================
2368
+
2369
+ // LSP initialization state
2370
+ const [_lspResult, setLspResult] = useState<LspIntegrationResult | null>(null);
2371
+ const [_lspLoading, setLspLoading] = useState(true);
2372
+
2373
+ // Initialize LSP on mount (non-blocking, graceful fallback)
2374
+ useEffect(() => {
2375
+ let cancelled = false;
2376
+ const isDebug = !!process.env.VELLUM_DEBUG;
2377
+
2378
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: LSP init with conditional logging
2379
+ const loadLsp = async () => {
2380
+ try {
2381
+ const result = await initializeLsp({
2382
+ workspaceRoot: process.cwd(),
2383
+ toolRegistry: toolRegistry as LspIntegrationOptions["toolRegistry"],
2384
+ autoInstall: true, // Auto-install missing language servers
2385
+ logger: isDebug
2386
+ ? {
2387
+ debug: (msg) => console.debug(`[lsp] ${msg}`),
2388
+ info: (msg) => console.info(`[lsp] ${msg}`),
2389
+ warn: (msg) => console.warn(`[lsp] ${msg}`),
2390
+ error: (msg) => console.error(`[lsp] ${msg}`),
2391
+ }
2392
+ : undefined,
2393
+ });
2394
+
2395
+ if (cancelled) return;
2396
+
2397
+ setLspResult(result);
2398
+ if (isDebug) {
2399
+ const msg = result.success
2400
+ ? `[lsp] Initialized with ${result.toolCount} tools, ${result.availableServers.length} servers available`
2401
+ : `[lsp] Initialization skipped: ${result.error}`;
2402
+ console.debug(msg);
2403
+ }
2404
+ setLspLoading(false);
2405
+ } catch (error) {
2406
+ if (cancelled) return;
2407
+ // LSP is optional - log but don't fail
2408
+ if (isDebug) console.debug("[lsp] Failed to initialize (non-critical):", error);
2409
+ setLspLoading(false);
2410
+ }
2411
+ };
2412
+
2413
+ void loadLsp();
2414
+
2415
+ return () => {
2416
+ cancelled = true;
2417
+ void disposeLsp();
2418
+ };
2419
+ }, [toolRegistry]);
2420
+
2421
+ const handleCommandEvent = useCallback(
2422
+ (event: string, data?: unknown) => {
2423
+ if (event === "app:exit") {
2424
+ // Show goodbye message
2425
+ addMessage({ role: "assistant", content: "Goodbye! See you next time." });
2426
+ // Give time for message to render, then exit
2427
+ setTimeout(() => {
2428
+ exit();
2429
+ setTimeout(() => process.exit(0), 50);
2430
+ }, 150);
2431
+ return;
2432
+ }
2433
+
2434
+ if (event === "session:resume") {
2435
+ const payload = data as ResumeSessionEventData | undefined;
2436
+ if (payload?.session) {
2437
+ switchToSession(payload.session.metadata.id, payload.session);
2438
+ setMessages([...toUIMessages(payload.session.messages)]);
2439
+ }
2440
+ }
2441
+ },
2442
+ [exit, addMessage, setMessages, switchToSession]
2443
+ );
2444
+
2445
+ const contextProviderRef = useRef<DefaultContextProvider | null>(null);
2446
+
2447
+ //: Create command executor with context provider
2448
+ const commandExecutor = useMemo(() => {
2449
+ if (!credentialManager) {
2450
+ return null;
2451
+ }
2452
+
2453
+ const contextProvider = createContextProvider({
2454
+ session: {
2455
+ id: activeSessionId,
2456
+ provider: currentProvider,
2457
+ cwd: process.cwd(),
2458
+ },
2459
+ credentials: credentialManager,
2460
+ toolRegistry,
2461
+ emit: handleCommandEvent,
2462
+ });
2463
+
2464
+ contextProviderRef.current = contextProvider as DefaultContextProvider;
2465
+ return new CommandExecutor(commandRegistry, contextProvider);
2466
+ }, [
2467
+ commandRegistry,
2468
+ credentialManager,
2469
+ toolRegistry,
2470
+ activeSessionId,
2471
+ currentProvider,
2472
+ handleCommandEvent,
2473
+ ]);
2474
+
2475
+ useEffect(() => {
2476
+ if (!contextProviderRef.current) {
2477
+ return;
2478
+ }
2479
+
2480
+ contextProviderRef.current.updateSession({
2481
+ id: activeSessionId,
2482
+ provider: currentProvider,
2483
+ cwd: process.cwd(),
2484
+ });
2485
+ }, [activeSessionId, currentProvider]);
2486
+
2487
+ useEffect(() => {
2488
+ if (!commandExecutor) {
2489
+ return;
2490
+ }
2491
+
2492
+ try {
2493
+ const batchWithExecutor = createBatchCommand(commandExecutor);
2494
+ commandRegistry.unregister(batchWithExecutor.name);
2495
+ commandRegistry.register(batchWithExecutor);
2496
+ bumpCommandRegistryVersion();
2497
+ } catch (error) {
2498
+ console.warn(
2499
+ "[commands] Failed to register batch command:",
2500
+ error instanceof Error ? error.message : String(error)
2501
+ );
2502
+ }
2503
+ }, [commandExecutor, commandRegistry, bumpCommandRegistryVersion]);
2504
+
2505
+ useEffect(() => {
2506
+ if (!storageReady) {
2507
+ return;
2508
+ }
2509
+
2510
+ const storage = storageManagerRef.current;
2511
+ const listService = sessionListServiceRef.current;
2512
+
2513
+ if (!storage || !listService) {
2514
+ return;
2515
+ }
2516
+
2517
+ try {
2518
+ const resumeWithStorage = createResumeCommand(storage, listService);
2519
+ commandRegistry.unregister(resumeWithStorage.name);
2520
+ commandRegistry.register(resumeWithStorage);
2521
+ bumpCommandRegistryVersion();
2522
+ } catch (error) {
2523
+ console.warn(
2524
+ "[commands] Failed to register resume command:",
2525
+ error instanceof Error ? error.message : String(error)
2526
+ );
2527
+ }
2528
+ }, [storageReady, commandRegistry, bumpCommandRegistryVersion]);
2529
+
2530
+ // Register search command when storage is ready
2531
+ useEffect(() => {
2532
+ if (!storageReady) {
2533
+ return;
2534
+ }
2535
+
2536
+ const storage = storageManagerRef.current;
2537
+ const searchService = searchServiceRef.current;
2538
+
2539
+ if (!storage || !searchService) {
2540
+ return;
2541
+ }
2542
+
2543
+ try {
2544
+ const searchWithStorage = createSearchCommand(storage, searchService);
2545
+ commandRegistry.unregister(searchWithStorage.name);
2546
+ commandRegistry.register(searchWithStorage);
2547
+ bumpCommandRegistryVersion();
2548
+ } catch (error) {
2549
+ console.warn(
2550
+ "[commands] Failed to register search command:",
2551
+ error instanceof Error ? error.message : String(error)
2552
+ );
2553
+ }
2554
+ }, [storageReady, commandRegistry, bumpCommandRegistryVersion]);
2555
+
2556
+ // Register shutdown cleanup on mount
2557
+ useEffect(() => {
2558
+ setShutdownCleanup(() => {
2559
+ if (cancellationRef.current) {
2560
+ cancellationRef.current.cancel("shutdown");
2561
+ }
2562
+ // Save session on shutdown
2563
+ void saveSession();
2564
+ });
2565
+
2566
+ return () => {
2567
+ setShutdownCleanup(null);
2568
+ };
2569
+ }, [saveSession]);
2570
+
2571
+ const handleCommandResult = useCallback(
2572
+ async function process(result: CommandResult): Promise<void> {
2573
+ switch (result.kind) {
2574
+ case "success": {
2575
+ if (result.message) {
2576
+ addMessage({ role: "assistant", content: result.message });
2577
+ }
2578
+ if (result.clearScreen) {
2579
+ clearMessages();
2580
+ void clearSession();
2581
+ }
2582
+ break;
2583
+ }
2584
+
2585
+ case "error": {
2586
+ addMessage({
2587
+ role: "assistant",
2588
+ content: `[x] ${result.message}${
2589
+ result.suggestions ? `\n Did you mean: ${result.suggestions.join(", ")}?` : ""
2590
+ }`,
2591
+ });
2592
+ break;
2593
+ }
2594
+
2595
+ case "interactive": {
2596
+ setPromptValue("");
2597
+ setInteractivePrompt(result.prompt);
2598
+ break;
2599
+ }
2600
+
2601
+ case "pending": {
2602
+ setPendingOperation(result.operation);
2603
+ try {
2604
+ const resolved = await result.operation.promise;
2605
+ await process(resolved);
2606
+ } catch (error) {
2607
+ addMessage({
2608
+ role: "assistant",
2609
+ content: `[x] ${error instanceof Error ? error.message : String(error)}`,
2610
+ });
2611
+ } finally {
2612
+ setPendingOperation(null);
2613
+ }
2614
+ break;
2615
+ }
2616
+ }
2617
+ },
2618
+ [addMessage, clearMessages, clearSession]
2619
+ );
2620
+
2621
+ const handlePromptSubmit = useCallback(async () => {
2622
+ if (!interactivePrompt) {
2623
+ return;
2624
+ }
2625
+
2626
+ const prompt = interactivePrompt;
2627
+ const input = promptValue.trim();
2628
+ const resolvedValue = input === "" ? (prompt.defaultValue ?? "") : input;
2629
+
2630
+ setInteractivePrompt(null);
2631
+ setPromptValue("");
2632
+
2633
+ try {
2634
+ const result = await prompt.handler(resolvedValue);
2635
+ await handleCommandResult(result);
2636
+ } catch (error) {
2637
+ addMessage({
2638
+ role: "assistant",
2639
+ content: `[x] ${error instanceof Error ? error.message : String(error)}`,
2640
+ });
2641
+ }
2642
+ }, [interactivePrompt, promptValue, handleCommandResult, addMessage]);
2643
+
2644
+ const handlePromptCancel = useCallback(() => {
2645
+ if (!interactivePrompt) {
2646
+ return;
2647
+ }
2648
+
2649
+ const prompt = interactivePrompt;
2650
+ setInteractivePrompt(null);
2651
+ setPromptValue("");
2652
+
2653
+ if (prompt.onCancel) {
2654
+ void handleCommandResult(prompt.onCancel());
2655
+ }
2656
+ }, [interactivePrompt, handleCommandResult]);
2657
+
2658
+ const resolveFollowupResponse = useCallback(
2659
+ (rawValue: string, suggestions: readonly string[]): string => {
2660
+ const trimmed = rawValue.trim();
2661
+ let response = trimmed;
2662
+
2663
+ if (suggestions.length > 0 && trimmed.length > 0) {
2664
+ const index = Number.parseInt(trimmed, 10);
2665
+ if (!Number.isNaN(index) && index >= 1 && index <= suggestions.length) {
2666
+ response = suggestions[index - 1] ?? trimmed;
2667
+ } else {
2668
+ const match = suggestions.find(
2669
+ (option) => option.toLowerCase() === trimmed.toLowerCase()
2670
+ );
2671
+ if (match) {
2672
+ response = match;
2673
+ }
2674
+ }
2675
+ }
2676
+
2677
+ return response;
2678
+ },
2679
+ []
2680
+ );
2681
+
2682
+ // Handle Ctrl+C and ESC for cancellation
2683
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This handler must process multiple input types in a single callback for proper event handling
2684
+ useInput((inputChar, key) => {
2685
+ if (interactivePrompt) {
2686
+ if (key.escape) {
2687
+ handlePromptCancel();
2688
+ }
2689
+ return;
2690
+ }
2691
+
2692
+ if (followupPrompt && key.escape) {
2693
+ agentLoopProp?.submitUserResponse("");
2694
+ setFollowupPrompt(null);
2695
+ return;
2696
+ }
2697
+
2698
+ if (pendingOperation) {
2699
+ if (key.escape && pendingOperation.cancel) {
2700
+ pendingOperation.cancel();
2701
+ setPendingOperation(null);
2702
+ }
2703
+ return;
2704
+ }
2705
+
2706
+ // Handle vim mode key processing
2707
+ if (vimEnabled && vim.enabled) {
2708
+ const vimAction = vim.handleKey(inputChar, { ctrl: key.ctrl, shift: key.shift });
2709
+ if (vimAction) {
2710
+ // Vim action was processed - announce mode changes
2711
+ if (vimAction.type === "mode") {
2712
+ announce(`Vim: ${vimAction.target} mode`);
2713
+ }
2714
+ // Don't process further if vim handled it (unless it's a mode exit to NORMAL)
2715
+ if (vimAction.type !== "mode" || vimAction.target !== "NORMAL") {
2716
+ return;
2717
+ }
2718
+ }
2719
+ }
2720
+
2721
+ // Handle copy mode navigation
2722
+ if (copyMode.state.active) {
2723
+ if (key.escape) {
2724
+ copyMode.exitCopyMode();
2725
+ announce("Copy mode exited");
2726
+ return;
2727
+ }
2728
+ if (key.upArrow) {
2729
+ copyMode.expandSelection("up");
2730
+ return;
2731
+ }
2732
+ if (key.downArrow) {
2733
+ copyMode.expandSelection("down");
2734
+ return;
2735
+ }
2736
+ if (key.leftArrow) {
2737
+ copyMode.expandSelection("left");
2738
+ return;
2739
+ }
2740
+ if (key.rightArrow) {
2741
+ copyMode.expandSelection("right");
2742
+ return;
2743
+ }
2744
+ // Enter to copy selection
2745
+ if (key.return) {
2746
+ // Get content as 2D array from messages
2747
+ const content = messages.map((m) => m.content.split(""));
2748
+ void copyMode.copySelection(content).then(() => {
2749
+ announce("Selection copied to clipboard");
2750
+ copyMode.exitCopyMode();
2751
+ });
2752
+ return;
2753
+ }
2754
+ }
2755
+
2756
+ // ESC - cancel operation, exit copy mode, or exit app
2757
+ if (key.escape) {
2758
+ if (isLoading && cancellationRef.current) {
2759
+ // Cancel running operation
2760
+ cancellationRef.current.cancel("user_escape");
2761
+ setIsLoading(false);
2762
+ addMessage({ role: "assistant", content: "[Operation cancelled]" });
2763
+ } else {
2764
+ // No operation running, exit app
2765
+ addMessage({ role: "assistant", content: "Goodbye! See you next time." });
2766
+ setTimeout(() => {
2767
+ exit();
2768
+ setTimeout(() => process.exit(0), 50);
2769
+ }, 150);
2770
+ }
2771
+ return;
2772
+ }
2773
+
2774
+ // Ctrl+C - cancel operation when loading; otherwise exit
2775
+ if (key.ctrl && inputChar === "c") {
2776
+ if (isLoading && cancellationRef.current) {
2777
+ cancellationRef.current.cancel("user_ctrl_c");
2778
+ setIsLoading(false);
2779
+ addMessage({ role: "assistant", content: "[Operation cancelled by Ctrl+C]" });
2780
+ return;
2781
+ }
2782
+
2783
+ addMessage({ role: "assistant", content: "Goodbye! See you next time." });
2784
+ setTimeout(() => {
2785
+ exit();
2786
+ setTimeout(() => process.exit(0), 50);
2787
+ }, 150);
2788
+ return;
2789
+ }
2790
+ });
2791
+
2792
+ //: Handle slash command detection and execution
2793
+ const handleSlashCommand = useCallback(
2794
+ async (text: string): Promise<boolean> => {
2795
+ if (!text.trim().startsWith("/")) {
2796
+ return false; // Not a slash command
2797
+ }
2798
+
2799
+ if (!commandExecutor) {
2800
+ const normalized = text.trim().toLowerCase();
2801
+ const isExitCommand =
2802
+ normalized === "/exit" ||
2803
+ normalized === "/quit" ||
2804
+ normalized === "/q" ||
2805
+ normalized.startsWith("/exit ") ||
2806
+ normalized.startsWith("/quit ") ||
2807
+ normalized.startsWith("/q ");
2808
+
2809
+ if (isExitCommand) {
2810
+ addMessage({ role: "assistant", content: "Goodbye! See you next time." });
2811
+ setTimeout(() => {
2812
+ exit();
2813
+ setTimeout(() => process.exit(0), 50);
2814
+ }, 150);
2815
+ return true;
2816
+ }
2817
+
2818
+ addMessage({
2819
+ role: "assistant",
2820
+ content: "[x] Command system not ready yet. Please try again in a moment.",
2821
+ });
2822
+ return true;
2823
+ }
2824
+
2825
+ try {
2826
+ const result = await commandExecutor.execute(text);
2827
+ await handleCommandResult(result);
2828
+ } catch (error) {
2829
+ addMessage({
2830
+ role: "assistant",
2831
+ content: `[x] ${error instanceof Error ? error.message : String(error)}`,
2832
+ });
2833
+ }
2834
+
2835
+ return true; // Was a slash command
2836
+ },
2837
+ [commandExecutor, addMessage, handleCommandResult, exit]
2838
+ );
2839
+
2840
+ // Handle message submission (for CommandInput onMessage)
2841
+ const handleMessage = useCallback(
2842
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex message handling with multiple code paths
2843
+ async (text: string) => {
2844
+ if (!text.trim()) return;
2845
+
2846
+ if (followupPrompt && agentLoopProp) {
2847
+ const response = resolveFollowupResponse(text, followupPrompt.suggestions);
2848
+ setFollowupPrompt(null);
2849
+ addToHistory(response);
2850
+ addMessage({ role: "user", content: response });
2851
+ announce(`You said: ${response}`);
2852
+ agentLoopProp.submitUserResponse(response);
2853
+ return;
2854
+ }
2855
+
2856
+ // Fix 4: Reset turn usage when a new turn starts
2857
+ setTurnUsage({
2858
+ inputTokens: 0,
2859
+ outputTokens: 0,
2860
+ thinkingTokens: 0,
2861
+ cacheReadTokens: 0,
2862
+ cacheWriteTokens: 0,
2863
+ });
2864
+
2865
+ // Apply coding-mode handler transformations (vibe/plan/spec) and keep UI phase
2866
+ // progress in sync with SpecModeHandler's injected metadata.
2867
+ let processedText = text;
2868
+ try {
2869
+ const handlerResult = await modeManager.processMessage({
2870
+ content: text,
2871
+ timestamp: Date.now(),
2872
+ });
2873
+
2874
+ const modified = handlerResult.modifiedMessage;
2875
+ if (modified?.content) {
2876
+ processedText = modified.content;
2877
+ }
2878
+
2879
+ const phaseNumber = modified?.metadata?.phaseNumber;
2880
+ if (typeof phaseNumber === "number" && Number.isFinite(phaseNumber)) {
2881
+ setSpecPhase(phaseNumber);
2882
+ } else if (currentMode !== "spec") {
2883
+ // Keep PhaseProgressIndicator stable when leaving spec mode.
2884
+ setSpecPhase(1);
2885
+ }
2886
+
2887
+ if (handlerResult.requiresCheckpoint) {
2888
+ // Pause downstream processing until the user approves the checkpoint.
2889
+ setPromptValue("");
2890
+ setInteractivePrompt({
2891
+ inputType: "confirm",
2892
+ message: "Checkpoint required. Continue?",
2893
+ defaultValue: "n",
2894
+ handler: async (value: string): Promise<CommandResult> => {
2895
+ const confirmed = value.toLowerCase() === "y" || value.toLowerCase() === "yes";
2896
+ if (!confirmed) {
2897
+ return { kind: "success", message: "Checkpoint declined." };
2898
+ }
2899
+
2900
+ // Advance plan/spec handler state when possible.
2901
+ const checkpointResult = await modeManager.processMessage({
2902
+ content: "yes",
2903
+ timestamp: Date.now(),
2904
+ metadata: { advancePhase: true },
2905
+ });
2906
+
2907
+ const checkpointPhase = checkpointResult.modifiedMessage?.metadata?.phaseNumber;
2908
+ if (typeof checkpointPhase === "number" && Number.isFinite(checkpointPhase)) {
2909
+ setSpecPhase(checkpointPhase);
2910
+ }
2911
+
2912
+ return { kind: "success", message: "Checkpoint approved." };
2913
+ },
2914
+ onCancel: () => ({ kind: "success", message: "Checkpoint cancelled." }),
2915
+ });
2916
+ return;
2917
+ }
2918
+ } catch (error) {
2919
+ // Mode handling should never block primary chat; fall back to raw text.
2920
+ console.warn(
2921
+ "[mode] Failed to process message through mode handler:",
2922
+ error instanceof Error ? error.message : String(error)
2923
+ );
2924
+ }
2925
+
2926
+ // Add to input history
2927
+ addToHistory(processedText);
2928
+
2929
+ addMessage({ role: "user", content: processedText });
2930
+
2931
+ // Announce for screen reader
2932
+ announce(`You said: ${processedText}`);
2933
+
2934
+ const effectiveThinking = getEffectiveThinkingConfig(
2935
+ BUILTIN_CODING_MODES[currentMode]?.extendedThinking
2936
+ );
2937
+ if (effectiveThinking.enabled) {
2938
+ const modelInfo = getModelInfo(currentProvider, currentModel);
2939
+ const warningKey = `${currentProvider}/${currentModel}`;
2940
+ if (!modelInfo.supportsReasoning && !thinkingWarningRef.current.has(warningKey)) {
2941
+ addMessage({
2942
+ role: "assistant",
2943
+ content:
2944
+ `⚠️ Thinking mode is enabled, but ${currentProvider}/${modelInfo.name} ` +
2945
+ "does not support reasoning. Running this request without thinking.",
2946
+ });
2947
+ thinkingWarningRef.current.add(warningKey);
2948
+ }
2949
+ }
2950
+
2951
+ setIsLoading(true);
2952
+ // Thinking content is now integrated into the streaming message
2953
+ // via the agent-adapter's handleThinking function.
2954
+
2955
+ // Use AgentLoop if available
2956
+ if (agentLoopProp) {
2957
+ // Wire cancellation to AgentLoop
2958
+ cancellationRef.current = {
2959
+ cancel: (reason) => agentLoopProp.cancel(reason),
2960
+ get isCancelled() {
2961
+ const state = agentLoopProp.getState();
2962
+ return state === "terminated" || state === "shutdown";
2963
+ },
2964
+ };
2965
+
2966
+ try {
2967
+ agentLoopProp.addMessage(createUserMessage([SessionParts.text(processedText)]));
2968
+
2969
+ // Wrap agentLoop.run() with resilience (circuit breaker + rate limiter)
2970
+ if (resilientProvider) {
2971
+ const result = await resilientProvider.execute(currentProvider, () =>
2972
+ agentLoopProp.run()
2973
+ );
2974
+ if (!result.success && result.error) {
2975
+ // Check if circuit is open or rate limited
2976
+ const circuitState = resilientProvider.getCircuitState(currentProvider);
2977
+ if (circuitState === "OPEN") {
2978
+ addMessage({
2979
+ role: "assistant",
2980
+ content: `⚠️ Provider ${currentProvider} circuit breaker is open. Too many failures recently.`,
2981
+ });
2982
+ }
2983
+ throw result.error;
2984
+ }
2985
+ } else {
2986
+ // Fallback to direct execution if resilient provider not ready
2987
+ await agentLoopProp.run();
2988
+ }
2989
+
2990
+ // Messages are synced via AgentLoop adapter event handlers (handleText)
2991
+ // User message was already added via addMessage() above
2992
+ // Notify on completion
2993
+ notifyTaskComplete("Response received");
2994
+ announce("Response received");
2995
+ } catch (err) {
2996
+ const errorMsg = err instanceof Error ? err.message : String(err);
2997
+ // Log resilience metrics for debugging
2998
+ if (resilientProvider) {
2999
+ const stats = resilientProvider.getRateLimiterStats();
3000
+ console.debug("[Resilience] Stats:", stats);
3001
+ }
3002
+ notifyError(errorMsg);
3003
+ addMessage({ role: "assistant", content: `[x] Error: ${errorMsg}` });
3004
+ } finally {
3005
+ setIsLoading(false);
3006
+ cancellationRef.current = null;
3007
+ }
3008
+ return;
3009
+ }
3010
+
3011
+ // Fallback: Create a simple cancellation controller for simulated operation
3012
+ let cancelled = false;
3013
+ cancellationRef.current = {
3014
+ cancel: (reason) => {
3015
+ cancelled = true;
3016
+ console.log(`[Cancel] ${reason ?? "user request"}`);
3017
+ },
3018
+ get isCancelled() {
3019
+ return cancelled;
3020
+ },
3021
+ };
3022
+
3023
+ // Simulated response (with cancellation check) - fallback when no AgentLoop
3024
+ await new Promise<void>((resolve) => {
3025
+ const timeoutId = setTimeout(() => {
3026
+ if (!cancelled) {
3027
+ setTimeout(() => {
3028
+ if (!cancelled) {
3029
+ addMessage({ role: "assistant", content: `[Echo] ${processedText}` });
3030
+ notifyTaskComplete("Response received");
3031
+ announce("Response received");
3032
+ }
3033
+ resolve();
3034
+ }, 300);
3035
+ } else {
3036
+ resolve();
3037
+ }
3038
+ }, 500);
3039
+
3040
+ // Check for cancellation
3041
+ const checkInterval = setInterval(() => {
3042
+ if (cancelled) {
3043
+ clearTimeout(timeoutId);
3044
+ clearInterval(checkInterval);
3045
+ resolve();
3046
+ }
3047
+ }, 50);
3048
+ });
3049
+
3050
+ setIsLoading(false);
3051
+ cancellationRef.current = null;
3052
+ },
3053
+ [
3054
+ addToHistory,
3055
+ addMessage,
3056
+ announce,
3057
+ agentLoopProp,
3058
+ currentModel,
3059
+ currentMode,
3060
+ currentProvider,
3061
+ followupPrompt,
3062
+ modeManager,
3063
+ notifyTaskComplete,
3064
+ notifyError,
3065
+ resolveFollowupResponse,
3066
+ resilientProvider,
3067
+ ]
3068
+ );
3069
+
3070
+ // Handle slash command submission (for CommandInput onCommand)
3071
+ const handleCommand = useCallback(
3072
+ async (command: SlashCommand) => {
3073
+ // Add to history
3074
+ addToHistory(command.raw);
3075
+
3076
+ // Execute via the command executor
3077
+ const wasCommand = await handleSlashCommand(command.raw);
3078
+ if (!wasCommand) {
3079
+ addMessage({ role: "assistant", content: `Unknown command: /${command.name}` });
3080
+ }
3081
+ },
3082
+ [addToHistory, addMessage, handleSlashCommand]
3083
+ );
3084
+
3085
+ // Category order and labels for grouped slash command menu
3086
+ const categoryOrder = useMemo(
3087
+ () => ["system", "session", "navigation", "tools", "config", "auth", "debug"] as const,
3088
+ []
3089
+ );
3090
+
3091
+ const categoryLabels = useMemo(
3092
+ () => ({
3093
+ system: "System",
3094
+ session: "Session",
3095
+ navigation: "Navigation",
3096
+ tools: "Tools",
3097
+ config: "Config",
3098
+ auth: "Authentication",
3099
+ debug: "Debug",
3100
+ }),
3101
+ []
3102
+ );
3103
+
3104
+ // Get available command options for CommandInput autocomplete (structured with categories)
3105
+ const commandOptions = useMemo((): AutocompleteOption[] => {
3106
+ // Recompute when commands are registered dynamically (plugins/user commands).
3107
+ void commandRegistryVersion;
3108
+
3109
+ const options: AutocompleteOption[] = [];
3110
+ const seenNames = new Set<string>();
3111
+
3112
+ for (const cmd of commandRegistry.list()) {
3113
+ // Skip aliases as separate entries - they clutter the menu
3114
+ // (aliases still work when typed directly)
3115
+ if (!seenNames.has(cmd.name)) {
3116
+ seenNames.add(cmd.name);
3117
+ options.push({
3118
+ name: cmd.name,
3119
+ description: cmd.description,
3120
+ category: cmd.category,
3121
+ aliases: cmd.aliases,
3122
+ });
3123
+ }
3124
+ }
3125
+
3126
+ return options;
3127
+ }, [commandRegistry, commandRegistryVersion]);
3128
+
3129
+ // Get subcommands for a command (for two-level autocomplete)
3130
+ const getSubcommands = useCallback(
3131
+ (commandName: string): AutocompleteOption[] | undefined => {
3132
+ const cmd = commandRegistry.get(commandName);
3133
+ if (!cmd?.subcommands || cmd.subcommands.length === 0) {
3134
+ return undefined;
3135
+ }
3136
+ return cmd.subcommands.map((sub) => ({
3137
+ name: sub.name,
3138
+ description: sub.description,
3139
+ }));
3140
+ },
3141
+ [commandRegistry]
3142
+ );
3143
+
3144
+ // Get level 3 items for three-level autocomplete (e.g., /model anthropic claude-)
3145
+ const getLevel3Items = useCallback(
3146
+ (commandName: string, arg1: string, partial: string): AutocompleteOption[] | undefined => {
3147
+ // /model command: level 3 shows model IDs for the selected provider
3148
+ if (commandName === "model") {
3149
+ // arg1 is the provider name
3150
+ const models = getProviderModels(arg1);
3151
+ if (models.length === 0) {
3152
+ return undefined;
3153
+ }
3154
+
3155
+ // Filter models by partial match
3156
+ const lowerPartial = partial.toLowerCase();
3157
+ const filtered = lowerPartial
3158
+ ? models.filter(
3159
+ (m) =>
3160
+ m.id.toLowerCase().includes(lowerPartial) ||
3161
+ m.name.toLowerCase().includes(lowerPartial)
3162
+ )
3163
+ : models;
3164
+
3165
+ return filtered.map((m) => ({
3166
+ name: m.id,
3167
+ description: m.name,
3168
+ category: arg1,
3169
+ }));
3170
+ }
3171
+
3172
+ // /auth command: level 3 shows provider list for set/clear subcommands
3173
+ if (commandName === "auth") {
3174
+ const setAliases = ["set", "add", "login"];
3175
+ const clearAliases = ["clear", "remove", "delete", "logout"];
3176
+
3177
+ // Only show providers for set/clear subcommands (not for status)
3178
+ if (setAliases.includes(arg1) || clearAliases.includes(arg1)) {
3179
+ const providers = [
3180
+ "anthropic",
3181
+ "openai",
3182
+ "google",
3183
+ "azure",
3184
+ "bedrock",
3185
+ "vertex",
3186
+ "ollama",
3187
+ "openrouter",
3188
+ "together",
3189
+ "mistral",
3190
+ "cohere",
3191
+ "groq",
3192
+ "deepseek",
3193
+ "qwen",
3194
+ "xai",
3195
+ ];
3196
+
3197
+ const lowerPartial = partial.toLowerCase();
3198
+ const filtered = lowerPartial
3199
+ ? providers.filter((p) => p.toLowerCase().startsWith(lowerPartial))
3200
+ : providers;
3201
+
3202
+ return filtered.map((p) => ({
3203
+ name: p,
3204
+ description: `Configure ${p} API key`,
3205
+ }));
3206
+ }
3207
+
3208
+ return undefined; // status doesn't need level 3
3209
+ }
3210
+
3211
+ return undefined;
3212
+ },
3213
+ []
3214
+ );
3215
+
3216
+ // Handle permission dialog responses
3217
+ const handleApprove = useCallback(() => {
3218
+ if (!activeApproval) {
3219
+ return;
3220
+ }
3221
+
3222
+ announce("Tool execution approved");
3223
+ approveActive("once");
3224
+ }, [activeApproval, approveActive, announce]);
3225
+
3226
+ const handleApproveAlways = useCallback(() => {
3227
+ if (!activeApproval) {
3228
+ return;
3229
+ }
3230
+
3231
+ announce("Tool execution approved (always)");
3232
+ approveActive("always");
3233
+ }, [activeApproval, approveActive, announce]);
3234
+
3235
+ const handleReject = useCallback(() => {
3236
+ if (!activeApproval) {
3237
+ return;
3238
+ }
3239
+
3240
+ announce("Tool execution rejected");
3241
+ rejectActive();
3242
+ }, [activeApproval, rejectActive, announce]);
3243
+
3244
+ const requestModeSwitch = useCallback(
3245
+ async (mode: CodingMode) => {
3246
+ const result = await modeManager.switchMode(mode);
3247
+
3248
+ if (result.success) {
3249
+ setShowModeSelector(false);
3250
+ process.env.VELLUM_MODE = mode;
3251
+ announce(`Mode changed to ${mode}`);
3252
+ return;
3253
+ }
3254
+
3255
+ if (result.requiresConfirmation) {
3256
+ setShowModeSelector(false);
3257
+ openSpecConfirmation();
3258
+ return;
3259
+ }
3260
+
3261
+ addMessage({
3262
+ role: "assistant",
3263
+ content: `Unable to switch to ${mode}: ${result.reason ?? "Unknown error"}`,
3264
+ });
3265
+ },
3266
+ [modeManager, openSpecConfirmation, addMessage, announce]
3267
+ );
3268
+
3269
+ // Handle mode selection with persistence (FIX 5)
3270
+ const handleModeSelect = useCallback(
3271
+ (mode: CodingMode) => {
3272
+ void requestModeSwitch(mode);
3273
+ },
3274
+ [requestModeSwitch]
3275
+ );
3276
+
3277
+ // Handle model selection
3278
+ const handleModelSelect = useCallback(
3279
+ (selectedProvider: string, selectedModel: string) => {
3280
+ setCurrentProvider(selectedProvider);
3281
+ setCurrentModel(selectedModel);
3282
+ setShowModelSelector(false);
3283
+ announce(`Model changed to ${selectedModel} (${selectedProvider})`);
3284
+ },
3285
+ [announce]
3286
+ );
3287
+
3288
+ useEffect(() => {
3289
+ setModelCommandConfig(currentProvider, currentModel, handleModelSelect);
3290
+ }, [currentProvider, currentModel, handleModelSelect]);
3291
+
3292
+ // Handle onboarding completion
3293
+ const handleOnboardingComplete = useCallback(
3294
+ (result: { provider: string; mode: string; credentialsConfigured: boolean }) => {
3295
+ // Note: OnboardingWizard.saveConfig() already persists completed state
3296
+ setIsFirstRun(false);
3297
+ setShowOnboarding(false);
3298
+ setCurrentProvider(result.provider);
3299
+ // Task 4: Sync model with provider selection
3300
+ const defaultModel = getDefaultModelForProvider(result.provider);
3301
+ setCurrentModel(defaultModel);
3302
+ setCurrentMode(result.mode as CodingMode);
3303
+ void modeManager.forceSwitch(result.mode as CodingMode);
3304
+ // Task 5: Persist configuration (environment-based for now, config file in production)
3305
+ process.env.VELLUM_PROVIDER = result.provider;
3306
+ process.env.VELLUM_MODEL = defaultModel;
3307
+ process.env.VELLUM_MODE = result.mode;
3308
+ announce("Welcome to Vellum! Onboarding complete.");
3309
+ },
3310
+ [announce, modeManager]
3311
+ );
3312
+
3313
+ // Get context window for the current model
3314
+ const contextWindow = useMemo(
3315
+ () => getContextWindow(currentProvider, currentModel),
3316
+ [currentProvider, currentModel]
3317
+ );
3318
+
3319
+ // ==========================================================================
3320
+ // FIX 1: Subscribe to AgentLoop usage events for real token counting
3321
+ // ==========================================================================
3322
+ useEffect(() => {
3323
+ if (!agentLoopProp) {
3324
+ return;
3325
+ }
3326
+
3327
+ // Handle real usage events from AgentLoop
3328
+ const handleUsage = (usage: {
3329
+ inputTokens: number;
3330
+ outputTokens: number;
3331
+ thinkingTokens?: number;
3332
+ cacheReadTokens?: number;
3333
+ cacheWriteTokens?: number;
3334
+ }) => {
3335
+ // Update turn usage (per-turn tracking)
3336
+ setTurnUsage({
3337
+ inputTokens: usage.inputTokens,
3338
+ outputTokens: usage.outputTokens,
3339
+ thinkingTokens: usage.thinkingTokens ?? 0,
3340
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
3341
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
3342
+ });
3343
+
3344
+ // Update cumulative usage
3345
+ setTokenUsage((prev) => {
3346
+ const newUsage = {
3347
+ inputTokens: prev.inputTokens + usage.inputTokens,
3348
+ outputTokens: prev.outputTokens + usage.outputTokens,
3349
+ thinkingTokens: prev.thinkingTokens + (usage.thinkingTokens ?? 0),
3350
+ cacheReadTokens: prev.cacheReadTokens + (usage.cacheReadTokens ?? 0),
3351
+ cacheWriteTokens: prev.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
3352
+ totalCost: calculateCost(
3353
+ currentProvider,
3354
+ currentModel,
3355
+ prev.inputTokens + usage.inputTokens,
3356
+ prev.outputTokens + usage.outputTokens
3357
+ ),
3358
+ };
3359
+ tokenUsageRef.current = newUsage;
3360
+ return newUsage;
3361
+ });
3362
+
3363
+ // Track usage in cost service
3364
+ costService.trackUsage(
3365
+ {
3366
+ inputTokens: usage.inputTokens,
3367
+ outputTokens: usage.outputTokens,
3368
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
3369
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
3370
+ thinkingTokens: usage.thinkingTokens ?? 0,
3371
+ },
3372
+ currentModel,
3373
+ currentProvider
3374
+ );
3375
+ };
3376
+
3377
+ agentLoopProp.on("usage", handleUsage);
3378
+
3379
+ return () => {
3380
+ agentLoopProp.off("usage", handleUsage);
3381
+ };
3382
+ }, [agentLoopProp, currentModel, currentProvider, costService]);
3383
+
3384
+ // Handle user prompt requests from ask_followup_question (GAP 1)
3385
+ useEffect(() => {
3386
+ if (!agentLoopProp) {
3387
+ return;
3388
+ }
3389
+
3390
+ const handleUserPromptRequired = (prompt: { question: string; suggestions?: string[] }) => {
3391
+ const suggestions = prompt.suggestions ?? [];
3392
+
3393
+ setFollowupPrompt({
3394
+ question: prompt.question,
3395
+ suggestions,
3396
+ });
3397
+ };
3398
+
3399
+ agentLoopProp.on("userPrompt:required", handleUserPromptRequired);
3400
+
3401
+ return () => {
3402
+ agentLoopProp.off("userPrompt:required", handleUserPromptRequired);
3403
+ };
3404
+ }, [agentLoopProp]);
3405
+
3406
+ // Fallback: Update token usage from messages when no AgentLoop (simulated)
3407
+ useEffect(() => {
3408
+ // Only use fallback when no agentLoop is provided
3409
+ if (agentLoopProp) {
3410
+ return;
3411
+ }
3412
+
3413
+ // Approximate token count: ~4 chars per token (fallback only)
3414
+ const inputChars = messages
3415
+ .filter((m) => m.role === "user")
3416
+ .reduce((sum, m) => sum + m.content.length, 0);
3417
+ const outputChars = messages
3418
+ .filter((m) => m.role === "assistant")
3419
+ .reduce((sum, m) => sum + m.content.length, 0);
3420
+
3421
+ const inputTokens = Math.ceil(inputChars / 4);
3422
+ const outputTokens = Math.ceil(outputChars / 4);
3423
+ const totalCost = calculateCost(currentProvider, currentModel, inputTokens, outputTokens);
3424
+
3425
+ setTokenUsage((prev) => ({
3426
+ ...prev,
3427
+ inputTokens,
3428
+ outputTokens,
3429
+ totalCost,
3430
+ }));
3431
+ }, [messages, currentProvider, currentModel, agentLoopProp]);
3432
+
3433
+ // Calculate total tokens for StatusBar
3434
+ const totalTokens = tokenUsage.inputTokens + tokenUsage.outputTokens;
3435
+
3436
+ const dismissUpdateBanner = useCallback(() => {
3437
+ setUpdateAvailable(null);
3438
+ }, []);
3439
+
3440
+ const cancelOnboarding = useCallback(() => {
3441
+ setShowOnboarding(false);
3442
+ }, []);
3443
+
3444
+ const closeSessionManager = useCallback(() => {
3445
+ setShowSessionManager(false);
3446
+ }, []);
3447
+
3448
+ const handleSessionSelected = useCallback(
3449
+ (id: string) => {
3450
+ announce(`Selected session: ${id}`);
3451
+ switchToSession(id);
3452
+ setShowSessionManager(false);
3453
+ },
3454
+ [announce, switchToSession]
3455
+ );
3456
+
3457
+ const loadSessionPreviewMessages = useCallback(
3458
+ async (sessionId: string): Promise<readonly SessionPreviewMessage[] | null> => {
3459
+ const storage = storageManagerRef.current;
3460
+ if (!storage) {
3461
+ return null;
3462
+ }
3463
+
3464
+ try {
3465
+ const session = await storage.load(sessionId);
3466
+ const messages = session.messages;
3467
+
3468
+ // Keep preview lightweight: show last few messages only.
3469
+ const tail = messages.slice(Math.max(0, messages.length - 6));
3470
+
3471
+ return tail
3472
+ .map((message) => {
3473
+ const content = getTextContent(message).trim();
3474
+ if (!content) {
3475
+ return null;
3476
+ }
3477
+
3478
+ const role: SessionPreviewMessage["role"] =
3479
+ message.role === "tool_result"
3480
+ ? "tool"
3481
+ : message.role === "user"
3482
+ ? "user"
3483
+ : message.role === "assistant"
3484
+ ? "assistant"
3485
+ : "system";
3486
+
3487
+ return {
3488
+ id: message.id,
3489
+ role,
3490
+ content,
3491
+ timestamp: new Date(message.metadata.createdAt),
3492
+ };
3493
+ })
3494
+ .filter((msg): msg is NonNullable<typeof msg> => msg !== null);
3495
+ } catch {
3496
+ return null;
3497
+ }
3498
+ },
3499
+ []
3500
+ );
3501
+
3502
+ const promptPlaceholder = useMemo(() => {
3503
+ if (!interactivePrompt) {
3504
+ return "";
3505
+ }
3506
+
3507
+ if (interactivePrompt.placeholder) {
3508
+ return interactivePrompt.placeholder;
3509
+ }
3510
+
3511
+ if (interactivePrompt.inputType === "confirm") {
3512
+ return interactivePrompt.defaultValue?.toLowerCase() === "y" ? "Y/n" : "y/N";
3513
+ }
3514
+
3515
+ if (interactivePrompt.inputType === "select" && interactivePrompt.options?.length) {
3516
+ return `Choose 1-${interactivePrompt.options.length}`;
3517
+ }
3518
+
3519
+ return "";
3520
+ }, [interactivePrompt]);
3521
+
3522
+ // Get agent level from AgentConfig via registry
3523
+ const agentName = BUILTIN_CODING_MODES[currentMode].agentName;
3524
+
3525
+ return (
3526
+ <AppContentView
3527
+ agentName={agentName}
3528
+ announce={announce}
3529
+ alternateBufferEnabled={alternateBufferEnabled}
3530
+ activeApproval={activeApproval}
3531
+ activeRiskLevel={activeRiskLevel}
3532
+ activeSessionId={activeSessionId}
3533
+ backtrackState={backtrackState}
3534
+ bannerCycleDurationMs={bannerCycleDurationMs}
3535
+ bannerCycles={bannerCycles}
3536
+ bannerDisplayDurationMs={bannerDisplayDurationMs}
3537
+ bannerSplashComplete={bannerSplashComplete}
3538
+ bannerUpdateIntervalMs={bannerUpdateIntervalMs}
3539
+ branches={branches}
3540
+ cancelOnboarding={cancelOnboarding}
3541
+ closeSessionManager={closeSessionManager}
3542
+ commandOptions={commandOptions}
3543
+ credentialManager={credentialManager}
3544
+ getSubcommands={getSubcommands}
3545
+ getLevel3Items={getLevel3Items}
3546
+ categoryOrder={categoryOrder}
3547
+ categoryLabels={categoryLabels}
3548
+ checkpointDiff={checkpointDiff}
3549
+ closeCheckpointDiff={closeCheckpointDiff}
3550
+ onOpenCheckpointDiff={openCheckpointDiff}
3551
+ contextWindow={contextWindow}
3552
+ currentMode={currentMode}
3553
+ currentModel={currentModel}
3554
+ currentProvider={currentProvider}
3555
+ currentTip={currentTip}
3556
+ dismissTip={dismissTip}
3557
+ dismissUpdateBanner={dismissUpdateBanner}
3558
+ handleApprove={handleApprove}
3559
+ handleApproveAlways={handleApproveAlways}
3560
+ handleBannerComplete={handleBannerComplete}
3561
+ handleCommand={handleCommand}
3562
+ handleCreateBacktrackBranch={handleCreateBacktrackBranch}
3563
+ handleMessage={handleMessage}
3564
+ handleModeSelect={handleModeSelect}
3565
+ handleModelSelect={handleModelSelect}
3566
+ handleOnboardingComplete={handleOnboardingComplete}
3567
+ handlePromptSubmit={handlePromptSubmit}
3568
+ handleReject={handleReject}
3569
+ handleSessionSelected={handleSessionSelected}
3570
+ handleSwitchBacktrackBranch={handleSwitchBacktrackBranch}
3571
+ initError={initError}
3572
+ followupPrompt={followupPrompt}
3573
+ interactivePrompt={interactivePrompt}
3574
+ loadSessionPreviewMessages={loadSessionPreviewMessages}
3575
+ isLoading={isLoading}
3576
+ thinkingModeEnabled={thinkingModeEnabled}
3577
+ memoryEntries={memoryEntries}
3578
+ messages={messages}
3579
+ pendingMessage={pendingMessage}
3580
+ pendingOperation={pendingOperation}
3581
+ promptPlaceholder={promptPlaceholder}
3582
+ promptValue={promptValue}
3583
+ setPromptValue={setPromptValue}
3584
+ sessions={sessions}
3585
+ suppressPromptEnter={suppressPromptEnter}
3586
+ shouldShowBanner={shouldShowBanner}
3587
+ showModeSelector={showModeSelector}
3588
+ showModelSelector={showModelSelector}
3589
+ showOnboarding={showOnboarding}
3590
+ showSessionManager={showSessionManager}
3591
+ showHelpModal={showHelpModal}
3592
+ closeHelpModal={() => setShowHelpModal(false)}
3593
+ showApprovalQueue={showApprovalQueue}
3594
+ closeApprovalQueue={() => setShowApprovalQueue(false)}
3595
+ pendingApprovals={pendingApproval}
3596
+ onApproveQueueItem={(id) => approveExecution(id)}
3597
+ onRejectQueueItem={(id) => rejectExecution(id)}
3598
+ onApproveAll={() => approveAll()}
3599
+ onRejectAll={() =>
3600
+ pendingApproval.forEach((e) => {
3601
+ rejectExecution(e.id);
3602
+ })
3603
+ }
3604
+ showSidebar={showSidebar}
3605
+ sidebarContent={sidebarContent}
3606
+ specPhase={specPhase}
3607
+ themeContext={themeContext}
3608
+ taskChain={taskChain}
3609
+ currentTaskId={currentTaskId}
3610
+ todoItems={todoItems}
3611
+ refreshTodos={refreshTodos}
3612
+ toolRegistry={toolRegistry}
3613
+ tokenUsage={tokenUsage}
3614
+ turnUsage={turnUsage}
3615
+ totalTokens={totalTokens}
3616
+ trustMode={trustMode}
3617
+ undoBacktrack={undoBacktrack}
3618
+ updateAvailable={updateAvailable}
3619
+ redoBacktrack={redoBacktrack}
3620
+ workspace={workspaceName}
3621
+ branch={gitBranch}
3622
+ changedFiles={gitChangedFiles}
3623
+ persistence={{
3624
+ status: persistence.status,
3625
+ unsavedCount: persistence.unsavedCount,
3626
+ lastSavedAt: persistence.lastSavedAt,
3627
+ }}
3628
+ snapshots={snapshots}
3629
+ providerStatus={providerStatus}
3630
+ costWarningState={costWarningState}
3631
+ autoApprovalState={autoApprovalState}
3632
+ vimEnabled={vimEnabled}
3633
+ vimMode={vim.mode}
3634
+ />
3635
+ );
3636
+ }
3637
+
3638
+ type ThemeContextValue = ReturnType<typeof useTheme>;
3639
+ type TipValue = ReturnType<typeof useTipEngine>["currentTip"];
3640
+ type ToolApprovalState = ReturnType<typeof useToolApprovalController>;
3641
+
3642
+ interface AppContentViewProps {
3643
+ readonly agentName?: string;
3644
+ readonly announce: (message: string) => void;
3645
+ readonly alternateBufferEnabled: boolean;
3646
+ readonly activeApproval: ToolApprovalState["activeApproval"];
3647
+ readonly activeRiskLevel: ToolApprovalState["activeRiskLevel"];
3648
+ readonly activeSessionId: string;
3649
+ readonly backtrackState: ReturnType<typeof useBacktrack>["backtrackState"];
3650
+ readonly bannerCycleDurationMs: number;
3651
+ readonly bannerCycles: number;
3652
+ readonly bannerDisplayDurationMs: number;
3653
+ readonly bannerSplashComplete: boolean;
3654
+ readonly bannerUpdateIntervalMs: number;
3655
+ readonly branches: ReturnType<typeof useBacktrack>["branches"];
3656
+ readonly cancelOnboarding: () => void;
3657
+ readonly closeSessionManager: () => void;
3658
+ readonly commandOptions: readonly AutocompleteOption[];
3659
+ readonly credentialManager: CredentialManager | null;
3660
+ readonly getSubcommands: (commandName: string) => AutocompleteOption[] | undefined;
3661
+ readonly getLevel3Items: (
3662
+ commandName: string,
3663
+ arg1: string,
3664
+ partial: string
3665
+ ) => AutocompleteOption[] | undefined;
3666
+ readonly categoryOrder: readonly string[];
3667
+ readonly categoryLabels: Record<string, string>;
3668
+ readonly checkpointDiff: {
3669
+ content: string;
3670
+ snapshotHash?: string;
3671
+ isLoading: boolean;
3672
+ isVisible: boolean;
3673
+ };
3674
+ readonly closeCheckpointDiff: () => void;
3675
+ readonly onOpenCheckpointDiff: (hash: string) => void;
3676
+ readonly contextWindow: number;
3677
+ readonly currentMode: CodingMode;
3678
+ readonly currentModel: string;
3679
+ readonly currentProvider: string;
3680
+ readonly currentTip: TipValue;
3681
+ readonly dismissTip: () => void;
3682
+ readonly dismissUpdateBanner: () => void;
3683
+ readonly handleApprove: () => void;
3684
+ readonly handleApproveAlways: () => void;
3685
+ readonly handleBannerComplete: () => void;
3686
+ readonly handleCommand: (command: SlashCommand) => void;
3687
+ readonly handleCreateBacktrackBranch: () => void;
3688
+ readonly handleMessage: (text: string) => void;
3689
+ readonly handleModeSelect: (mode: CodingMode) => void;
3690
+ readonly handleModelSelect: (selectedProvider: string, selectedModel: string) => void;
3691
+ readonly handleOnboardingComplete: (result: {
3692
+ provider: string;
3693
+ mode: string;
3694
+ credentialsConfigured: boolean;
3695
+ }) => void;
3696
+ readonly handlePromptSubmit: () => void;
3697
+ readonly handleReject: () => void;
3698
+ readonly handleSessionSelected: (id: string) => void;
3699
+ readonly handleSwitchBacktrackBranch: (branchId: string) => void;
3700
+ readonly followupPrompt: { question: string; suggestions: string[] } | null;
3701
+ readonly interactivePrompt: InteractivePrompt | null;
3702
+ readonly loadSessionPreviewMessages: (
3703
+ sessionId: string
3704
+ ) => Promise<readonly SessionPreviewMessage[] | null>;
3705
+ readonly initError?: Error;
3706
+ readonly isLoading: boolean;
3707
+ readonly thinkingModeEnabled: boolean;
3708
+ readonly memoryEntries: MemoryPanelProps["entries"];
3709
+ readonly messages: readonly Message[];
3710
+ readonly pendingMessage: Message | null;
3711
+ readonly pendingOperation: AsyncOperation | null;
3712
+ readonly promptPlaceholder: string;
3713
+ readonly promptValue: string;
3714
+ readonly setPromptValue: (value: string) => void;
3715
+ readonly sessions: SessionMetadata[];
3716
+ readonly suppressPromptEnter: boolean;
3717
+ readonly shouldShowBanner: boolean;
3718
+ readonly showModeSelector: boolean;
3719
+ readonly showModelSelector: boolean;
3720
+ readonly showOnboarding: boolean;
3721
+ readonly showSessionManager: boolean;
3722
+ readonly showHelpModal: boolean;
3723
+ readonly closeHelpModal: () => void;
3724
+ readonly showApprovalQueue: boolean;
3725
+ readonly closeApprovalQueue: () => void;
3726
+ readonly pendingApprovals: readonly ToolExecution[];
3727
+ readonly onApproveQueueItem: (id: string) => void;
3728
+ readonly onRejectQueueItem: (id: string) => void;
3729
+ readonly onApproveAll: () => void;
3730
+ readonly onRejectAll: () => void;
3731
+ readonly showSidebar: boolean;
3732
+ readonly sidebarContent: SidebarContent;
3733
+ readonly specPhase: number;
3734
+ readonly themeContext: ThemeContextValue;
3735
+ readonly taskChain: TaskChain | null;
3736
+ readonly currentTaskId?: string;
3737
+ readonly todoItems: readonly TodoItemData[];
3738
+ readonly refreshTodos: () => void;
3739
+ readonly toolRegistry: ToolRegistry;
3740
+ readonly tokenUsage: {
3741
+ inputTokens: number;
3742
+ outputTokens: number;
3743
+ thinkingTokens: number;
3744
+ cacheReadTokens: number;
3745
+ cacheWriteTokens: number;
3746
+ totalCost: number;
3747
+ };
3748
+ readonly turnUsage: {
3749
+ inputTokens: number;
3750
+ outputTokens: number;
3751
+ thinkingTokens: number;
3752
+ cacheReadTokens: number;
3753
+ cacheWriteTokens: number;
3754
+ };
3755
+ readonly totalTokens: number;
3756
+ readonly trustMode: TrustMode;
3757
+ readonly undoBacktrack: () => void;
3758
+ readonly redoBacktrack: () => void;
3759
+ readonly updateAvailable: { current: string; latest: string } | null;
3760
+ /** Workspace name for header separator */
3761
+ readonly workspace: string;
3762
+ /** Git branch for header separator */
3763
+ readonly branch: string | null;
3764
+ /** Number of changed files for header separator */
3765
+ readonly changedFiles: number;
3766
+ /** Persistence status for session save indicator */
3767
+ readonly persistence?: {
3768
+ status: PersistenceStatus;
3769
+ unsavedCount: number;
3770
+ lastSavedAt: Date | null;
3771
+ };
3772
+ /** Snapshots hook result for checkpoint panel */
3773
+ readonly snapshots: ReturnType<typeof useSnapshots>;
3774
+ /** Provider status for ModelStatusBar */
3775
+ readonly providerStatus: ReturnType<typeof useProviderStatus>;
3776
+ /** Cost warning state */
3777
+ readonly costWarningState: {
3778
+ show: boolean;
3779
+ limitReached: boolean;
3780
+ percentUsed: number;
3781
+ costLimit: number;
3782
+ requestLimit: number;
3783
+ };
3784
+ /** Auto-approval status */
3785
+ readonly autoApprovalState: {
3786
+ consecutiveRequests: number;
3787
+ requestLimit: number;
3788
+ consecutiveCost: number;
3789
+ costLimit: number;
3790
+ requestPercentUsed: number;
3791
+ costPercentUsed: number;
3792
+ limitReached: boolean;
3793
+ limitType?: "requests" | "cost";
3794
+ } | null;
3795
+ /** Whether vim mode is enabled */
3796
+ readonly vimEnabled: boolean;
3797
+ /** Current vim mode */
3798
+ readonly vimMode: VimMode;
3799
+ }
3800
+
3801
+ function renderSidebarContent({
3802
+ announce,
3803
+ showSidebar,
3804
+ sidebarContent,
3805
+ todoItems,
3806
+ refreshTodos,
3807
+ memoryEntries,
3808
+ toolRegistry,
3809
+ persistence,
3810
+ snapshots,
3811
+ taskChain,
3812
+ currentTaskId,
3813
+ onOpenCheckpointDiff,
3814
+ }: {
3815
+ readonly announce: (message: string) => void;
3816
+ readonly showSidebar: boolean;
3817
+ readonly sidebarContent: SidebarContent;
3818
+ readonly todoItems: readonly TodoItemData[];
3819
+ readonly refreshTodos: () => void;
3820
+ readonly memoryEntries: MemoryPanelProps["entries"];
3821
+ readonly toolRegistry: ToolRegistry;
3822
+ readonly persistence?: {
3823
+ status: PersistenceStatus;
3824
+ unsavedCount: number;
3825
+ lastSavedAt: Date | null;
3826
+ };
3827
+ readonly snapshots: ReturnType<typeof useSnapshots>;
3828
+ readonly taskChain: TaskChain | null;
3829
+ readonly currentTaskId?: string;
3830
+ readonly onOpenCheckpointDiff: (hash: string) => void;
3831
+ }): React.ReactNode | undefined {
3832
+ if (!showSidebar) return undefined;
3833
+
3834
+ let panelContent: React.ReactNode;
3835
+
3836
+ if (sidebarContent === "todo") {
3837
+ panelContent = (
3838
+ <TodoPanel
3839
+ items={todoItems}
3840
+ isFocused={showSidebar}
3841
+ maxHeight={20}
3842
+ onRefresh={refreshTodos}
3843
+ onActivateItem={(item) => {
3844
+ announce(`Selected: ${item.title}`);
3845
+ }}
3846
+ />
3847
+ );
3848
+ } else if (sidebarContent === "tools") {
3849
+ panelContent = <ToolsPanel isFocused={showSidebar} maxItems={20} />;
3850
+ } else if (sidebarContent === "mcp") {
3851
+ panelContent = <McpPanel isFocused={showSidebar} toolRegistry={toolRegistry} />;
3852
+ } else if (sidebarContent === "snapshots") {
3853
+ panelContent = (
3854
+ <SnapshotCheckpointPanel
3855
+ snapshots={snapshots.snapshots}
3856
+ isLoading={snapshots.isLoading}
3857
+ error={snapshots.error}
3858
+ isInitialized={snapshots.isInitialized}
3859
+ isFocused={showSidebar}
3860
+ maxHeight={20}
3861
+ onRestore={async (hash) => {
3862
+ const result = await snapshots.restore(hash);
3863
+ if (result.success) {
3864
+ announce(`Restored ${result.files.length} files from checkpoint`);
3865
+ } else {
3866
+ announce(`Restore failed: ${result.error}`);
3867
+ }
3868
+ }}
3869
+ onDiff={async (hash) => {
3870
+ onOpenCheckpointDiff(hash);
3871
+ }}
3872
+ onTakeCheckpoint={async () => {
3873
+ try {
3874
+ await snapshots.take("Manual checkpoint");
3875
+ announce("Checkpoint created");
3876
+ } catch (err) {
3877
+ announce(
3878
+ `Failed to create checkpoint: ${err instanceof Error ? err.message : String(err)}`
3879
+ );
3880
+ }
3881
+ }}
3882
+ onRefresh={() => void snapshots.refresh()}
3883
+ />
3884
+ );
3885
+ } else {
3886
+ panelContent = <MemoryPanel entries={memoryEntries} isFocused={showSidebar} maxHeight={20} />;
3887
+ }
3888
+
3889
+ return (
3890
+ <Box flexDirection="column" height="100%">
3891
+ <Box flexGrow={1}>{panelContent}</Box>
3892
+ {taskChain && taskChain.nodes.size > 0 && (
3893
+ <Box marginTop={1}>
3894
+ <MaxSizedBox maxHeight={12} truncationIndicator="... (more tasks)">
3895
+ <AgentProgress
3896
+ chain={taskChain}
3897
+ currentTaskId={currentTaskId}
3898
+ showDetails={false}
3899
+ progressBarWidth={12}
3900
+ />
3901
+ </MaxSizedBox>
3902
+ </Box>
3903
+ )}
3904
+ <SystemStatusPanel compact={false} persistence={persistence} />
3905
+ </Box>
3906
+ );
3907
+ }
3908
+
3909
+ interface AppOverlaysProps {
3910
+ readonly activeApproval: ToolApprovalState["activeApproval"];
3911
+ readonly activeRiskLevel: ToolApprovalState["activeRiskLevel"];
3912
+ readonly activeSessionId: string;
3913
+ readonly checkpointDiff: {
3914
+ content: string;
3915
+ snapshotHash?: string;
3916
+ isLoading: boolean;
3917
+ isVisible: boolean;
3918
+ };
3919
+ readonly closeCheckpointDiff: () => void;
3920
+ readonly closeSessionManager: () => void;
3921
+ readonly currentMode: CodingMode;
3922
+ readonly currentModel: string;
3923
+ readonly currentProvider: string;
3924
+ readonly dismissUpdateBanner: () => void;
3925
+ readonly handleApprove: () => void;
3926
+ readonly handleApproveAlways: () => void;
3927
+ readonly handleModeSelect: (mode: CodingMode) => void;
3928
+ readonly handleModelSelect: (selectedProvider: string, selectedModel: string) => void;
3929
+ readonly handleReject: () => void;
3930
+ readonly handleSessionSelected: (id: string) => void;
3931
+ readonly loadSessionPreviewMessages: (
3932
+ sessionId: string
3933
+ ) => Promise<readonly import("./tui/components/session/types.js").SessionPreviewMessage[] | null>;
3934
+ readonly pendingOperation: AsyncOperation | null;
3935
+ readonly sessions: SessionMetadata[];
3936
+ readonly showModeSelector: boolean;
3937
+ readonly showModelSelector: boolean;
3938
+ readonly showSessionManager: boolean;
3939
+ readonly showHelpModal: boolean;
3940
+ readonly closeHelpModal: () => void;
3941
+ readonly showApprovalQueue: boolean;
3942
+ readonly closeApprovalQueue: () => void;
3943
+ readonly pendingApprovals: readonly ToolExecution[];
3944
+ readonly onApproveQueueItem: (id: string) => void;
3945
+ readonly onRejectQueueItem: (id: string) => void;
3946
+ readonly onApproveAll: () => void;
3947
+ readonly onRejectAll: () => void;
3948
+ readonly themeContext: ThemeContextValue;
3949
+ readonly updateAvailable: { current: string; latest: string } | null;
3950
+ }
3951
+
3952
+ function AppOverlays({
3953
+ activeApproval,
3954
+ activeRiskLevel,
3955
+ activeSessionId,
3956
+ checkpointDiff,
3957
+ closeCheckpointDiff,
3958
+ closeSessionManager,
3959
+ currentMode,
3960
+ currentModel,
3961
+ currentProvider,
3962
+ dismissUpdateBanner,
3963
+ handleApprove,
3964
+ handleApproveAlways,
3965
+ handleModeSelect,
3966
+ handleModelSelect,
3967
+ handleReject,
3968
+ handleSessionSelected,
3969
+ loadSessionPreviewMessages,
3970
+ pendingOperation,
3971
+ sessions,
3972
+ showModeSelector,
3973
+ showModelSelector,
3974
+ showSessionManager,
3975
+ showHelpModal,
3976
+ closeHelpModal,
3977
+ showApprovalQueue,
3978
+ closeApprovalQueue: _closeApprovalQueue,
3979
+ pendingApprovals,
3980
+ onApproveQueueItem,
3981
+ onRejectQueueItem,
3982
+ onApproveAll,
3983
+ onRejectAll,
3984
+ themeContext,
3985
+ updateAvailable,
3986
+ }: AppOverlaysProps): React.JSX.Element {
3987
+ return (
3988
+ <>
3989
+ {updateAvailable && (
3990
+ <UpdateBanner
3991
+ currentVersion={updateAvailable.current}
3992
+ latestVersion={updateAvailable.latest}
3993
+ dismissible
3994
+ onDismiss={dismissUpdateBanner}
3995
+ compact
3996
+ />
3997
+ )}
3998
+
3999
+ {showModeSelector && (
4000
+ <Box
4001
+ position="absolute"
4002
+ marginTop={5}
4003
+ marginLeft={10}
4004
+ borderStyle="round"
4005
+ borderColor={themeContext.theme.colors.info}
4006
+ padding={1}
4007
+ >
4008
+ <ModeSelector
4009
+ currentMode={currentMode}
4010
+ onSelect={handleModeSelect}
4011
+ isActive={showModeSelector}
4012
+ showDescriptions
4013
+ />
4014
+ </Box>
4015
+ )}
4016
+
4017
+ {showSessionManager && (
4018
+ <Box
4019
+ position="absolute"
4020
+ marginTop={3}
4021
+ marginLeft={5}
4022
+ borderStyle="round"
4023
+ borderColor={themeContext.theme.colors.primary}
4024
+ padding={1}
4025
+ >
4026
+ <SessionPicker
4027
+ sessions={sessions}
4028
+ activeSessionId={activeSessionId}
4029
+ loadPreviewMessages={loadSessionPreviewMessages}
4030
+ onSelect={handleSessionSelected}
4031
+ onClose={closeSessionManager}
4032
+ isOpen={showSessionManager}
4033
+ />
4034
+ </Box>
4035
+ )}
4036
+
4037
+ {activeApproval && (
4038
+ <Box
4039
+ position="absolute"
4040
+ marginTop={5}
4041
+ marginLeft={10}
4042
+ borderStyle="double"
4043
+ borderColor={themeContext.theme.colors.warning}
4044
+ padding={1}
4045
+ >
4046
+ <PermissionDialog
4047
+ execution={activeApproval}
4048
+ riskLevel={activeRiskLevel}
4049
+ onApprove={handleApprove}
4050
+ onApproveAlways={handleApproveAlways}
4051
+ onReject={handleReject}
4052
+ isFocused
4053
+ />
4054
+ </Box>
4055
+ )}
4056
+
4057
+ {showModelSelector && (
4058
+ <Box
4059
+ position="absolute"
4060
+ marginTop={5}
4061
+ marginLeft={10}
4062
+ borderStyle="round"
4063
+ borderColor={themeContext.theme.colors.success}
4064
+ padding={1}
4065
+ >
4066
+ <ModelSelector
4067
+ currentModel={currentModel}
4068
+ currentProvider={currentProvider}
4069
+ onSelect={handleModelSelect}
4070
+ isActive={showModelSelector}
4071
+ showDetails
4072
+ />
4073
+ </Box>
4074
+ )}
4075
+
4076
+ {pendingOperation && (
4077
+ <Box
4078
+ position="absolute"
4079
+ marginTop={4}
4080
+ marginLeft={8}
4081
+ borderStyle="round"
4082
+ borderColor={themeContext.theme.colors.warning}
4083
+ padding={1}
4084
+ flexDirection="column"
4085
+ minWidth={40}
4086
+ >
4087
+ <LoadingIndicator message={pendingOperation.message} />
4088
+ {pendingOperation.cancel && <Text dimColor>Press Esc to cancel</Text>}
4089
+ </Box>
4090
+ )}
4091
+
4092
+ {showHelpModal && (
4093
+ <Box
4094
+ position="absolute"
4095
+ marginTop={5}
4096
+ marginLeft={10}
4097
+ borderStyle="round"
4098
+ borderColor={themeContext.theme.colors.info}
4099
+ padding={1}
4100
+ >
4101
+ <HotkeyHelpModal
4102
+ isVisible={showHelpModal}
4103
+ onClose={closeHelpModal}
4104
+ hotkeys={DEFAULT_HOTKEYS}
4105
+ />
4106
+ </Box>
4107
+ )}
4108
+
4109
+ {showApprovalQueue && pendingApprovals.length > 1 && (
4110
+ <Box
4111
+ position="absolute"
4112
+ marginTop={3}
4113
+ marginLeft={5}
4114
+ borderStyle="double"
4115
+ borderColor={themeContext.theme.colors.warning}
4116
+ padding={1}
4117
+ >
4118
+ <ApprovalQueue
4119
+ executions={pendingApprovals}
4120
+ onApprove={onApproveQueueItem}
4121
+ onReject={onRejectQueueItem}
4122
+ onApproveAll={onApproveAll}
4123
+ onRejectAll={onRejectAll}
4124
+ isFocused={showApprovalQueue}
4125
+ />
4126
+ </Box>
4127
+ )}
4128
+
4129
+ {checkpointDiff.isVisible && (
4130
+ <Box
4131
+ position="absolute"
4132
+ marginTop={3}
4133
+ marginLeft={5}
4134
+ borderStyle="double"
4135
+ borderColor={themeContext.theme.colors.info}
4136
+ padding={1}
4137
+ >
4138
+ <CheckpointDiffView
4139
+ diffContent={checkpointDiff.content}
4140
+ snapshotHash={checkpointDiff.snapshotHash}
4141
+ isFocused={checkpointDiff.isVisible}
4142
+ isLoading={checkpointDiff.isLoading}
4143
+ maxHeight={24}
4144
+ onClose={closeCheckpointDiff}
4145
+ />
4146
+ </Box>
4147
+ )}
4148
+ </>
4149
+ );
4150
+ }
4151
+
4152
+ interface AppHeaderProps {
4153
+ readonly backtrackState: ReturnType<typeof useBacktrack>["backtrackState"];
4154
+ readonly branches: ReturnType<typeof useBacktrack>["branches"];
4155
+ readonly currentMode: CodingMode;
4156
+ readonly currentTip: TipValue;
4157
+ readonly dismissTip: () => void;
4158
+ readonly handleCreateBacktrackBranch: () => void;
4159
+ readonly handleSwitchBacktrackBranch: (branchId: string) => void;
4160
+ readonly initError?: Error;
4161
+ readonly redoBacktrack: () => void;
4162
+ readonly specPhase: number;
4163
+ readonly tokenUsage: {
4164
+ inputTokens: number;
4165
+ outputTokens: number;
4166
+ totalCost: number;
4167
+ };
4168
+ readonly undoBacktrack: () => void;
4169
+ }
4170
+
4171
+ function AppHeader({
4172
+ backtrackState,
4173
+ branches,
4174
+ currentMode,
4175
+ currentTip,
4176
+ dismissTip,
4177
+ handleCreateBacktrackBranch,
4178
+ handleSwitchBacktrackBranch,
4179
+ initError,
4180
+ redoBacktrack,
4181
+ specPhase,
4182
+ tokenUsage,
4183
+ undoBacktrack,
4184
+ }: AppHeaderProps): React.JSX.Element {
4185
+ const fileStats = useFileChangeStats();
4186
+ const showFileChanges = fileStats.additions > 0 || fileStats.deletions > 0;
4187
+
4188
+ return (
4189
+ <Box flexDirection="column">
4190
+ <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
4191
+ <ModeIndicator mode={currentMode} specPhase={specPhase} compact />
4192
+ <CostDisplay
4193
+ inputTokens={tokenUsage.inputTokens}
4194
+ outputTokens={tokenUsage.outputTokens}
4195
+ totalCost={tokenUsage.totalCost}
4196
+ compact
4197
+ />
4198
+ </Box>
4199
+ {initError && <InitErrorBanner error={initError} />}
4200
+ {(currentTip || showFileChanges) && (
4201
+ <Box flexDirection="row" justifyContent="space-between">
4202
+ <Box flexGrow={1}>
4203
+ {currentTip && <TipBanner tip={currentTip} onDismiss={dismissTip} compact />}
4204
+ </Box>
4205
+ {showFileChanges && (
4206
+ <Box flexDirection="row">
4207
+ <Text color="gray">diff </Text>
4208
+ <FileChangesIndicator
4209
+ additions={fileStats.additions}
4210
+ deletions={fileStats.deletions}
4211
+ />
4212
+ </Box>
4213
+ )}
4214
+ </Box>
4215
+ )}
4216
+ {currentMode === "spec" && (
4217
+ <PhaseProgressIndicator currentPhase={specPhase} showLabels showPercentage />
4218
+ )}
4219
+ {backtrackState.historyLength > 1 && (
4220
+ <BacktrackControls
4221
+ backtrackState={backtrackState}
4222
+ branches={branches}
4223
+ onUndo={undoBacktrack}
4224
+ onRedo={redoBacktrack}
4225
+ onCreateBranch={handleCreateBacktrackBranch}
4226
+ onSwitchBranch={handleSwitchBacktrackBranch}
4227
+ />
4228
+ )}
4229
+ </Box>
4230
+ );
4231
+ }
4232
+
4233
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex TUI view with many conditional renders and state handlers
4234
+ function AppContentView({
4235
+ agentName,
4236
+ announce,
4237
+ alternateBufferEnabled,
4238
+ activeApproval,
4239
+ activeRiskLevel,
4240
+ activeSessionId,
4241
+ backtrackState,
4242
+ bannerCycleDurationMs,
4243
+ bannerCycles,
4244
+ bannerDisplayDurationMs,
4245
+ bannerSplashComplete,
4246
+ bannerUpdateIntervalMs,
4247
+ branches,
4248
+ cancelOnboarding,
4249
+ closeSessionManager,
4250
+ commandOptions,
4251
+ credentialManager,
4252
+ getSubcommands,
4253
+ getLevel3Items,
4254
+ categoryOrder,
4255
+ categoryLabels,
4256
+ checkpointDiff,
4257
+ closeCheckpointDiff,
4258
+ onOpenCheckpointDiff,
4259
+ contextWindow,
4260
+ currentMode,
4261
+ currentModel,
4262
+ currentProvider,
4263
+ currentTip,
4264
+ dismissTip,
4265
+ dismissUpdateBanner,
4266
+ handleApprove,
4267
+ handleApproveAlways,
4268
+ handleBannerComplete,
4269
+ handleCommand,
4270
+ handleCreateBacktrackBranch,
4271
+ handleMessage,
4272
+ handleModeSelect,
4273
+ handleModelSelect,
4274
+ handleOnboardingComplete,
4275
+ handlePromptSubmit,
4276
+ handleReject,
4277
+ handleSessionSelected,
4278
+ handleSwitchBacktrackBranch,
4279
+ initError,
4280
+ followupPrompt,
4281
+ interactivePrompt,
4282
+ loadSessionPreviewMessages,
4283
+ isLoading,
4284
+ thinkingModeEnabled,
4285
+ memoryEntries,
4286
+ messages,
4287
+ pendingMessage,
4288
+ pendingOperation,
4289
+ promptPlaceholder,
4290
+ promptValue,
4291
+ setPromptValue,
4292
+ sessions,
4293
+ suppressPromptEnter,
4294
+ shouldShowBanner,
4295
+ showModeSelector,
4296
+ showModelSelector,
4297
+ showOnboarding,
4298
+ showSessionManager,
4299
+ showHelpModal,
4300
+ closeHelpModal,
4301
+ showApprovalQueue,
4302
+ closeApprovalQueue,
4303
+ pendingApprovals,
4304
+ onApproveQueueItem,
4305
+ onRejectQueueItem,
4306
+ onApproveAll,
4307
+ onRejectAll,
4308
+ showSidebar,
4309
+ sidebarContent,
4310
+ specPhase,
4311
+ themeContext,
4312
+ taskChain,
4313
+ currentTaskId,
4314
+ todoItems,
4315
+ refreshTodos,
4316
+ toolRegistry,
4317
+ tokenUsage,
4318
+ turnUsage,
4319
+ totalTokens,
4320
+ trustMode,
4321
+ undoBacktrack,
4322
+ redoBacktrack,
4323
+ updateAvailable,
4324
+ workspace,
4325
+ branch,
4326
+ changedFiles,
4327
+ persistence,
4328
+ snapshots,
4329
+ providerStatus,
4330
+ costWarningState,
4331
+ autoApprovalState,
4332
+ vimEnabled,
4333
+ vimMode,
4334
+ }: AppContentViewProps): React.JSX.Element {
4335
+ const sidebar = renderSidebarContent({
4336
+ announce,
4337
+ showSidebar,
4338
+ sidebarContent,
4339
+ todoItems,
4340
+ refreshTodos,
4341
+ memoryEntries,
4342
+ toolRegistry,
4343
+ persistence,
4344
+ snapshots,
4345
+ taskChain,
4346
+ currentTaskId,
4347
+ onOpenCheckpointDiff,
4348
+ });
4349
+
4350
+ const footer = (
4351
+ <StatusBar
4352
+ mode={currentMode}
4353
+ agentName={agentName}
4354
+ modelName={currentModel}
4355
+ tokens={{
4356
+ current: totalTokens,
4357
+ max: contextWindow,
4358
+ breakdown: {
4359
+ inputTokens: tokenUsage.inputTokens,
4360
+ outputTokens: tokenUsage.outputTokens,
4361
+ thinkingTokens: tokenUsage.thinkingTokens,
4362
+ cacheReadTokens: tokenUsage.cacheReadTokens,
4363
+ cacheWriteTokens: tokenUsage.cacheWriteTokens,
4364
+ },
4365
+ turnUsage: {
4366
+ inputTokens: turnUsage.inputTokens,
4367
+ outputTokens: turnUsage.outputTokens,
4368
+ thinkingTokens: turnUsage.thinkingTokens,
4369
+ cacheReadTokens: turnUsage.cacheReadTokens,
4370
+ cacheWriteTokens: turnUsage.cacheWriteTokens,
4371
+ },
4372
+ showBreakdown: true,
4373
+ }}
4374
+ cost={tokenUsage.totalCost}
4375
+ trustMode={trustMode}
4376
+ thinking={{ active: thinkingModeEnabled }}
4377
+ showAllModes={showModeSelector}
4378
+ persistence={persistence}
4379
+ />
4380
+ );
4381
+
4382
+ // Extended footer with ModelStatusBar and warnings
4383
+ const extendedFooter = (
4384
+ <Box flexDirection="column">
4385
+ {/* Cost warning - show above status bar when approaching/exceeding limit */}
4386
+ {costWarningState.show && (
4387
+ <CostWarning
4388
+ costUsed={tokenUsage.totalCost}
4389
+ costLimit={costWarningState.costLimit}
4390
+ requestsUsed={0}
4391
+ requestLimit={costWarningState.requestLimit}
4392
+ percentUsed={costWarningState.percentUsed}
4393
+ limitReached={costWarningState.limitReached}
4394
+ compact={true}
4395
+ severity={costWarningState.limitReached ? "error" : "warning"}
4396
+ />
4397
+ )}
4398
+ {/* Auto-approval status - show when auto-approvals are active */}
4399
+ {autoApprovalState && autoApprovalState.consecutiveRequests > 0 && (
4400
+ <AutoApprovalStatus
4401
+ consecutiveRequests={autoApprovalState.consecutiveRequests}
4402
+ requestLimit={autoApprovalState.requestLimit}
4403
+ consecutiveCost={autoApprovalState.consecutiveCost}
4404
+ costLimit={autoApprovalState.costLimit}
4405
+ requestPercentUsed={autoApprovalState.requestPercentUsed}
4406
+ costPercentUsed={autoApprovalState.costPercentUsed}
4407
+ limitReached={autoApprovalState.limitReached}
4408
+ limitType={autoApprovalState.limitType}
4409
+ compact={true}
4410
+ />
4411
+ )}
4412
+ {/* Model status bar - shows provider health */}
4413
+ {providerStatus.providers.length > 1 && (
4414
+ <Box marginBottom={1}>
4415
+ <ModelStatusBar providers={providerStatus.providers} compact={true} maxVisible={5} />
4416
+ </Box>
4417
+ )}
4418
+ {/* Main status bar */}
4419
+ {footer}
4420
+ </Box>
4421
+ );
4422
+
4423
+ // Conditional rendering to avoid hook count mismatch from early returns
4424
+ const showBannerView = shouldShowBanner && !bannerSplashComplete;
4425
+ const showMainView = !showOnboarding && !showBannerView;
4426
+
4427
+ const commandPlaceholder = followupPrompt
4428
+ ? "Reply to follow-up..."
4429
+ : isLoading
4430
+ ? "Thinking..."
4431
+ : "Type a message or /command...";
4432
+
4433
+ const commandInputDisabled =
4434
+ (isLoading && !followupPrompt) || !!interactivePrompt || !!pendingOperation;
4435
+
4436
+ const commandInputFocused =
4437
+ (!isLoading || !!followupPrompt) &&
4438
+ !showModeSelector &&
4439
+ !showModelSelector &&
4440
+ !showSessionManager &&
4441
+ !showHelpModal &&
4442
+ !activeApproval &&
4443
+ !interactivePrompt &&
4444
+ !pendingOperation;
4445
+
4446
+ const headerContent = (
4447
+ <AppHeader
4448
+ backtrackState={backtrackState}
4449
+ branches={branches}
4450
+ currentMode={currentMode}
4451
+ currentTip={currentTip}
4452
+ dismissTip={dismissTip}
4453
+ handleCreateBacktrackBranch={handleCreateBacktrackBranch}
4454
+ handleSwitchBacktrackBranch={handleSwitchBacktrackBranch}
4455
+ initError={initError}
4456
+ redoBacktrack={redoBacktrack}
4457
+ specPhase={specPhase}
4458
+ tokenUsage={tokenUsage}
4459
+ undoBacktrack={undoBacktrack}
4460
+ />
4461
+ );
4462
+
4463
+ const layoutBody = (
4464
+ <>
4465
+ <Box flexDirection="column" flexGrow={1}>
4466
+ {/* Thinking content is now integrated into messages via the `thinking` field */}
4467
+ {/* T-VIRTUAL-SCROLL: Pass historyMessages for Static rendering optimization */}
4468
+ <MessageList
4469
+ messages={messages}
4470
+ historyMessages={messages.filter((m) => !m.isStreaming)}
4471
+ pendingMessage={pendingMessage}
4472
+ isLoading={isLoading}
4473
+ useVirtualizedList={true}
4474
+ estimatedItemHeight={4}
4475
+ scrollKeyMode={commandInputFocused ? "page" : "all"}
4476
+ forceFollowOnInput={true}
4477
+ useAltBuffer={alternateBufferEnabled}
4478
+ enableScroll={!alternateBufferEnabled}
4479
+ isFocused={
4480
+ !showModeSelector &&
4481
+ !showModelSelector &&
4482
+ !showSessionManager &&
4483
+ !showHelpModal &&
4484
+ !activeApproval &&
4485
+ !interactivePrompt &&
4486
+ !pendingOperation
4487
+ }
4488
+ />
4489
+ </Box>
4490
+
4491
+ <Box flexShrink={0} flexDirection="column">
4492
+ {/* Followup prompt with suggestions - use OptionSelector for keyboard navigation */}
4493
+ {followupPrompt && followupPrompt.suggestions.length > 0 && (
4494
+ <OptionSelector
4495
+ question={followupPrompt.question}
4496
+ options={followupPrompt.suggestions}
4497
+ onSelect={(option) => {
4498
+ handleMessage(option);
4499
+ }}
4500
+ onCancel={() => {
4501
+ handleMessage("");
4502
+ }}
4503
+ isFocused={commandInputFocused}
4504
+ />
4505
+ )}
4506
+ {/* Followup prompt without suggestions - show simple text prompt */}
4507
+ {followupPrompt && followupPrompt.suggestions.length === 0 && (
4508
+ <Box marginTop={1} flexDirection="column">
4509
+ <Text color={themeContext.theme.semantic.text.secondary}>
4510
+ ↳ {followupPrompt.question}
4511
+ </Text>
4512
+ <Text dimColor>Type your reply and press Enter (Esc to skip)</Text>
4513
+ </Box>
4514
+ )}
4515
+ {interactivePrompt && (
4516
+ <Box
4517
+ borderStyle="round"
4518
+ borderColor={themeContext.theme.colors.warning}
4519
+ paddingX={2}
4520
+ paddingY={1}
4521
+ marginY={1}
4522
+ flexDirection="column"
4523
+ >
4524
+ {/* Title section */}
4525
+ {interactivePrompt.title && (
4526
+ <Box
4527
+ borderStyle="single"
4528
+ borderBottom
4529
+ borderColor={themeContext.theme.colors.warning}
4530
+ marginBottom={1}
4531
+ >
4532
+ <Text bold color={themeContext.theme.colors.warning}>
4533
+ 🔐 {interactivePrompt.title}
4534
+ </Text>
4535
+ </Box>
4536
+ )}
4537
+ {/* Help text section */}
4538
+ {interactivePrompt.helpText && (
4539
+ <Box marginBottom={1}>
4540
+ <Text color={themeContext.theme.semantic.text.muted}>
4541
+ {interactivePrompt.helpText}
4542
+ </Text>
4543
+ </Box>
4544
+ )}
4545
+ {/* Format hint */}
4546
+ {interactivePrompt.formatHint && (
4547
+ <Text color={themeContext.theme.semantic.text.muted}>
4548
+ 📋 Format: {interactivePrompt.formatHint}
4549
+ </Text>
4550
+ )}
4551
+ {/* Documentation URL hint */}
4552
+ {interactivePrompt.documentationUrl && (
4553
+ <Text color={themeContext.theme.semantic.text.muted}>
4554
+ 📚 Docs: {interactivePrompt.documentationUrl}
4555
+ </Text>
4556
+ )}
4557
+ {/* Input area with spacing */}
4558
+ <Box flexDirection="column" marginTop={1}>
4559
+ {/* Original message (e.g., "API Key:") */}
4560
+ <Text>{interactivePrompt.message}</Text>
4561
+ {/* Select options */}
4562
+ {interactivePrompt.inputType === "select" && interactivePrompt.options && (
4563
+ <Box flexDirection="column" marginTop={1}>
4564
+ {interactivePrompt.options.map((option, index) => (
4565
+ <Text key={option}>{`${index + 1}. ${option}`}</Text>
4566
+ ))}
4567
+ </Box>
4568
+ )}
4569
+ {/* Input field */}
4570
+ <Box marginTop={1} flexGrow={1}>
4571
+ <Text color={themeContext.theme.semantic.text.muted}>{promptPlaceholder} </Text>
4572
+ <Box flexGrow={1}>
4573
+ <TextInput
4574
+ value={promptValue}
4575
+ onChange={setPromptValue}
4576
+ onSubmit={handlePromptSubmit}
4577
+ mask={interactivePrompt.inputType === "password" ? "*" : undefined}
4578
+ focused={!suppressPromptEnter}
4579
+ suppressEnter={suppressPromptEnter}
4580
+ showBorder={false}
4581
+ />
4582
+ </Box>
4583
+ </Box>
4584
+ </Box>
4585
+ {/* Footer hint */}
4586
+ <Box marginTop={1}>
4587
+ <Text dimColor>Press Enter to submit, Esc to cancel</Text>
4588
+ </Box>
4589
+ </Box>
4590
+ )}
4591
+
4592
+ {/* Focus Debug: logs focus conditions when they change */}
4593
+ <FocusDebugger
4594
+ isLoading={isLoading}
4595
+ showModeSelector={showModeSelector}
4596
+ showModelSelector={showModelSelector}
4597
+ showSessionManager={showSessionManager}
4598
+ showHelpModal={showHelpModal}
4599
+ activeApproval={activeApproval}
4600
+ interactivePrompt={interactivePrompt}
4601
+ pendingOperation={pendingOperation}
4602
+ />
4603
+ {/* Vim mode indicator (shown above input when vim mode is enabled) */}
4604
+ {vimEnabled && (
4605
+ <Box marginBottom={0}>
4606
+ <VimModeIndicator enabled={vimEnabled} mode={vimMode} />
4607
+ </Box>
4608
+ )}
4609
+ <EnhancedCommandInput
4610
+ onMessage={handleMessage}
4611
+ onCommand={handleCommand}
4612
+ commands={commandOptions}
4613
+ getSubcommands={getSubcommands}
4614
+ getLevel3Items={getLevel3Items}
4615
+ groupedCommands={true}
4616
+ categoryOrder={categoryOrder}
4617
+ categoryLabels={categoryLabels}
4618
+ placeholder={commandPlaceholder}
4619
+ disabled={commandInputDisabled}
4620
+ focused={commandInputFocused}
4621
+ historyKey="vellum-command-history"
4622
+ cwd={process.cwd()}
4623
+ />
4624
+ </Box>
4625
+ </>
4626
+ );
4627
+
4628
+ const screenReaderStatus = pendingOperation
4629
+ ? pendingOperation.message
4630
+ : isLoading
4631
+ ? "Thinking..."
4632
+ : "Ready";
4633
+
4634
+ const screenReaderContent = (
4635
+ <Box flexDirection="column">
4636
+ {layoutBody}
4637
+ {showSidebar && sidebar && (
4638
+ <Box marginTop={1} flexDirection="column">
4639
+ <Text color={themeContext.theme.semantic.text.muted}>Sidebar</Text>
4640
+ {sidebar}
4641
+ </Box>
4642
+ )}
4643
+ </Box>
4644
+ );
4645
+
4646
+ return (
4647
+ <>
4648
+ {showOnboarding && (
4649
+ <OnboardingWizard
4650
+ onComplete={handleOnboardingComplete}
4651
+ onCancel={cancelOnboarding}
4652
+ credentialManager={credentialManager ?? undefined}
4653
+ />
4654
+ )}
4655
+
4656
+ {!showOnboarding && showBannerView && (
4657
+ <Box
4658
+ flexDirection="column"
4659
+ alignItems="center"
4660
+ justifyContent="center"
4661
+ height={process.stdout.rows ?? 24}
4662
+ >
4663
+ <Banner
4664
+ animated
4665
+ autoHide
4666
+ cycles={bannerCycles}
4667
+ displayDuration={bannerDisplayDurationMs}
4668
+ cycleDuration={bannerCycleDurationMs}
4669
+ updateInterval={bannerUpdateIntervalMs}
4670
+ onComplete={handleBannerComplete}
4671
+ />
4672
+ </Box>
4673
+ )}
4674
+
4675
+ {showMainView && (
4676
+ <>
4677
+ <AppOverlays
4678
+ activeApproval={activeApproval}
4679
+ activeRiskLevel={activeRiskLevel}
4680
+ activeSessionId={activeSessionId}
4681
+ checkpointDiff={checkpointDiff}
4682
+ closeCheckpointDiff={closeCheckpointDiff}
4683
+ closeSessionManager={closeSessionManager}
4684
+ currentMode={currentMode}
4685
+ currentModel={currentModel}
4686
+ currentProvider={currentProvider}
4687
+ dismissUpdateBanner={dismissUpdateBanner}
4688
+ handleApprove={handleApprove}
4689
+ handleApproveAlways={handleApproveAlways}
4690
+ handleModeSelect={handleModeSelect}
4691
+ handleModelSelect={handleModelSelect}
4692
+ handleReject={handleReject}
4693
+ handleSessionSelected={handleSessionSelected}
4694
+ loadSessionPreviewMessages={loadSessionPreviewMessages}
4695
+ pendingOperation={pendingOperation}
4696
+ sessions={sessions}
4697
+ showModeSelector={showModeSelector}
4698
+ showModelSelector={showModelSelector}
4699
+ showSessionManager={showSessionManager}
4700
+ showHelpModal={showHelpModal}
4701
+ closeHelpModal={closeHelpModal}
4702
+ showApprovalQueue={showApprovalQueue}
4703
+ closeApprovalQueue={closeApprovalQueue}
4704
+ pendingApprovals={pendingApprovals}
4705
+ onApproveQueueItem={onApproveQueueItem}
4706
+ onRejectQueueItem={onRejectQueueItem}
4707
+ onApproveAll={onApproveAll}
4708
+ onRejectAll={onRejectAll}
4709
+ themeContext={themeContext}
4710
+ updateAvailable={updateAvailable}
4711
+ />
4712
+
4713
+ <AdaptiveLayout
4714
+ regularLayout={
4715
+ <Layout
4716
+ header={headerContent}
4717
+ footer={extendedFooter}
4718
+ sidebar={sidebar}
4719
+ showSidebar={showSidebar}
4720
+ workspace={workspace}
4721
+ branch={branch ?? undefined}
4722
+ changedFiles={changedFiles}
4723
+ >
4724
+ {layoutBody}
4725
+ </Layout>
4726
+ }
4727
+ header={headerContent}
4728
+ footer={extendedFooter}
4729
+ status={screenReaderStatus}
4730
+ >
4731
+ {screenReaderContent}
4732
+ </AdaptiveLayout>
4733
+ </>
4734
+ )}
4735
+ </>
4736
+ );
4737
+ }