@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
@@ -0,0 +1,1719 @@
1
+ /**
2
+ * MessageList Component (T017)
3
+ *
4
+ * Displays a list of messages with auto-scroll support and optimized rendering.
5
+ * Uses Ink's <Static> component for completed messages (never re-render)
6
+ * and only re-renders the pending streaming message.
7
+ *
8
+ * Key optimization: Static rendering pattern from Gemini CLI
9
+ * - historyMessages: Rendered in <Static>, never re-render
10
+ * - pendingMessage: Only this causes re-renders during streaming
11
+ *
12
+ * Virtualized mode (useVirtualizedList=true):
13
+ * - Only renders visible items for optimal performance
14
+ * - Best for very long conversations (100+ messages)
15
+ * - Uses VirtualizedList component ported from Gemini CLI
16
+ *
17
+ * @module tui/components/Messages/MessageList
18
+ */
19
+
20
+ import { getIcons } from "@vellum/shared";
21
+ import { Box, type Key, Static, Text, useInput } from "ink";
22
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import { getThinkingDisplayMode, subscribeToDisplayMode } from "../../../commands/think.js";
24
+ import { useAnimationFrame } from "../../context/AnimationContext.js";
25
+ import type { Message, ToolCallInfo } from "../../context/MessagesContext.js";
26
+ import { useAlternateBuffer } from "../../hooks/useAlternateBuffer.js";
27
+ import { useAnimatedScrollbar } from "../../hooks/useAnimatedScrollbar.js";
28
+ import { useDiffMode } from "../../hooks/useDiffMode.js";
29
+ import { useKeyboardScroll } from "../../hooks/useKeyboardScroll.js";
30
+ import { type ModeControllerConfig, useModeController } from "../../hooks/useModeController.js";
31
+ import { useScrollController } from "../../hooks/useScrollController.js";
32
+ import type { ThinkingDisplayMode } from "../../i18n/index.js";
33
+ import { useTheme } from "../../theme/index.js";
34
+ import { isEndKey, isHomeKey } from "../../types/ink-extended.js";
35
+ import { estimateMessageHeight } from "../../utils/heightEstimator.js";
36
+ import { MaxSizedBox } from "../common/MaxSizedBox.js";
37
+ import { NewMessagesBadge } from "../common/NewMessagesBadge.js";
38
+ import { ScrollIndicator } from "../common/ScrollIndicator.js";
39
+ import { StreamingIndicator } from "../common/StreamingIndicator.js";
40
+ import {
41
+ SCROLL_TO_ITEM_END,
42
+ VirtualizedList,
43
+ type VirtualizedListRef,
44
+ } from "../common/VirtualizedList/index.js";
45
+ import { DiffView } from "./DiffView.js";
46
+ import { MarkdownRenderer } from "./MarkdownRenderer.js";
47
+ import { ThinkingBlock } from "./ThinkingBlock.js";
48
+ import { ToolResultPreview } from "./ToolResultPreview.js";
49
+
50
+ // =============================================================================
51
+ // Constants
52
+ // =============================================================================
53
+
54
+ /** ASCII text spinner animation frames for running tools (no Unicode/emoji) */
55
+ const SPINNER_FRAMES = ["-", "\\", "|", "/"] as const;
56
+
57
+ /** Enable debug logging for TUI mode decisions */
58
+ const DEBUG_TUI = process.env.NODE_ENV === "development" && process.env.DEBUG_TUI;
59
+
60
+ // =============================================================================
61
+ // Types
62
+ // =============================================================================
63
+
64
+ /**
65
+ * Props for the MessageList component.
66
+ */
67
+ export interface MessageListProps {
68
+ /** Array of messages to display (for backward compatibility) */
69
+ readonly messages: readonly Message[];
70
+ /** Completed messages for <Static> rendering (never re-render) */
71
+ readonly historyMessages?: readonly Message[];
72
+ /** Currently streaming message (only this causes re-renders) */
73
+ readonly pendingMessage?: Message | null;
74
+ /** Whether the agent is currently processing (shows thinking indicator) */
75
+ readonly isLoading?: boolean;
76
+ /** Whether to automatically scroll to bottom on new messages (default: true) */
77
+ readonly autoScroll?: boolean;
78
+ /** Callback when scroll position changes relative to bottom */
79
+ readonly onScrollChange?: (isAtBottom: boolean) => void;
80
+ /** Maximum height in lines (optional, for windowed display) */
81
+ readonly maxHeight?: number;
82
+ /**
83
+ * Enable virtualized rendering for optimal performance with large lists.
84
+ * When true, only visible messages are rendered.
85
+ * Best for conversations with 100+ messages.
86
+ * @default false
87
+ */
88
+ readonly useVirtualizedList?: boolean;
89
+ /**
90
+ * Estimated height per message in lines (for virtualization).
91
+ * Can be a fixed number or function for variable heights.
92
+ * @default 4
93
+ */
94
+ readonly estimatedItemHeight?: number | ((index: number) => number);
95
+ /**
96
+ * Whether this component has focus for keyboard input.
97
+ * Controls whether PageUp/PageDown/arrow keys work.
98
+ * @default true (active when not specified for backward compatibility)
99
+ */
100
+ readonly isFocused?: boolean;
101
+ /**
102
+ * Which keys are allowed for scroll control.
103
+ * - "all": arrows, PageUp/PageDown, Home/End (default)
104
+ * - "page": only PageUp/PageDown to avoid stealing input navigation
105
+ */
106
+ readonly scrollKeyMode?: "all" | "page";
107
+ /**
108
+ * When true, any non-scroll key while manually scrolled jumps back to latest.
109
+ * This keeps auto-follow "sticky" unless the user is actively scrolling.
110
+ * @default true
111
+ */
112
+ readonly forceFollowOnInput?: boolean;
113
+ /**
114
+ * Render mode configuration override.
115
+ * Controls thresholds for switching between static/windowed/virtualized modes.
116
+ */
117
+ readonly modeConfig?: ModeControllerConfig;
118
+ /**
119
+ * Whether to enable adaptive mode switching based on content height.
120
+ * When false, falls back to legacy behavior.
121
+ * @default true
122
+ */
123
+ readonly adaptive?: boolean;
124
+ /**
125
+ * Whether to use the alternate terminal buffer.
126
+ * Required for adaptive mode viewport calculation.
127
+ * @default false
128
+ */
129
+ readonly useAltBuffer?: boolean;
130
+ /**
131
+ * Enable new scroll controller with follow/manual modes.
132
+ * When enabled, adds ScrollIndicator and NewMessagesBadge.
133
+ * @default false
134
+ */
135
+ readonly enableScroll?: boolean;
136
+ }
137
+
138
+ // =============================================================================
139
+ // Helper Functions
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Format a timestamp for display.
144
+ */
145
+ function formatTimestamp(date: Date): string {
146
+ return date.toLocaleTimeString(undefined, {
147
+ hour: "2-digit",
148
+ minute: "2-digit",
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Get the display icon for a message role.
154
+ */
155
+ function getRoleIcon(role: Message["role"]): string {
156
+ const icons = getIcons();
157
+ switch (role) {
158
+ case "user":
159
+ return icons.user;
160
+ case "assistant":
161
+ return icons.assistant;
162
+ case "system":
163
+ return icons.system;
164
+ case "tool":
165
+ return icons.tool;
166
+ default:
167
+ return icons.info;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get the role display label.
173
+ */
174
+ function getRoleLabel(role: Message["role"]): string {
175
+ switch (role) {
176
+ case "user":
177
+ return "You";
178
+ case "assistant":
179
+ return "Vellum";
180
+ case "system":
181
+ return "System";
182
+ case "tool":
183
+ return "Tool";
184
+ default:
185
+ return "Unknown";
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Legacy estimateMessageHeight wrapper for virtualization.
191
+ * Uses the extracted heightEstimator utility internally.
192
+ */
193
+ function estimateMessageHeightLegacy(
194
+ message: Message,
195
+ width: number,
196
+ includeToolCalls: boolean
197
+ ): number {
198
+ return estimateMessageHeight(message, { width, includeToolCalls });
199
+ }
200
+
201
+ interface DiffMetadata {
202
+ readonly diff: string;
203
+ readonly additions: number;
204
+ readonly deletions: number;
205
+ }
206
+
207
+ function isDiffMetadata(value: unknown): value is DiffMetadata {
208
+ if (!value || typeof value !== "object") {
209
+ return false;
210
+ }
211
+ const obj = value as Record<string, unknown>;
212
+ return (
213
+ typeof obj.diff === "string" &&
214
+ typeof obj.additions === "number" &&
215
+ typeof obj.deletions === "number"
216
+ );
217
+ }
218
+
219
+ function getDiffMetadata(result: unknown): DiffMetadata | null {
220
+ if (isDiffMetadata(result)) {
221
+ return result;
222
+ }
223
+ if (!result || typeof result !== "object") {
224
+ return null;
225
+ }
226
+ const diffMeta = (result as Record<string, unknown>).diffMeta;
227
+ return isDiffMetadata(diffMeta) ? diffMeta : null;
228
+ }
229
+
230
+ // =============================================================================
231
+ // Sub-Components
232
+ // =============================================================================
233
+
234
+ /**
235
+ * Props for inline tool call indicator.
236
+ */
237
+ interface InlineToolCallProps {
238
+ readonly toolCall: ToolCallInfo;
239
+ readonly accentColor: string;
240
+ readonly mutedColor: string;
241
+ readonly successColor: string;
242
+ readonly errorColor: string;
243
+ }
244
+
245
+ /**
246
+ * Renders an inline tool call with status indicator (spinner/checkmark/error).
247
+ * Gemini-style: shows tool name with animated spinner while running,
248
+ * checkmark when completed, X when error.
249
+ */
250
+ const InlineToolCall = memo(function InlineToolCall({
251
+ toolCall,
252
+ accentColor,
253
+ mutedColor,
254
+ successColor,
255
+ errorColor,
256
+ }: InlineToolCallProps) {
257
+ // Use animation frame for spinner (only animates when running)
258
+ const frameIndex = useAnimationFrame(SPINNER_FRAMES);
259
+ // Get current diff view mode
260
+ const { mode: diffMode } = useDiffMode();
261
+
262
+ // Determine status indicator and color
263
+ let statusIcon: string;
264
+ let statusColor: string;
265
+
266
+ switch (toolCall.status) {
267
+ case "running":
268
+ case "pending":
269
+ statusIcon = SPINNER_FRAMES[frameIndex] ?? "-";
270
+ statusColor = accentColor;
271
+ break;
272
+ case "completed":
273
+ statusIcon = "+";
274
+ statusColor = successColor;
275
+ break;
276
+ case "error":
277
+ statusIcon = "x";
278
+ statusColor = errorColor;
279
+ break;
280
+ default:
281
+ statusIcon = "o";
282
+ statusColor = mutedColor;
283
+ }
284
+
285
+ const diffMeta = getDiffMetadata(toolCall.result);
286
+ const hasDiffStats =
287
+ toolCall.status === "completed" &&
288
+ diffMeta !== null &&
289
+ (diffMeta.additions > 0 || diffMeta.deletions > 0);
290
+ const hasDiffContent =
291
+ toolCall.status === "completed" &&
292
+ diffMeta !== null &&
293
+ diffMeta.diff.trim() !== "" &&
294
+ diffMeta.diff !== "(no changes)";
295
+
296
+ // Show result preview for non-diff tool results
297
+ const hasResultPreview =
298
+ toolCall.status === "completed" &&
299
+ !hasDiffContent &&
300
+ toolCall.result !== undefined &&
301
+ toolCall.result !== null;
302
+
303
+ return (
304
+ <Box flexDirection="column">
305
+ <Box flexDirection="row">
306
+ <Text color={statusColor}>{statusIcon}</Text>
307
+ <Text> </Text>
308
+ <Text color={accentColor} bold>
309
+ {toolCall.name}
310
+ </Text>
311
+ {hasDiffStats && diffMeta && (
312
+ <>
313
+ <Text> </Text>
314
+ <Text color={successColor}>+{diffMeta.additions}</Text>
315
+ <Text> </Text>
316
+ <Text color={errorColor}>-{diffMeta.deletions}</Text>
317
+ </>
318
+ )}
319
+ {/* Show error message inline if present */}
320
+ {toolCall.status === "error" && toolCall.error && (
321
+ <Text color={errorColor} dimColor>
322
+ {" "}
323
+ — {toolCall.error}
324
+ </Text>
325
+ )}
326
+ </Box>
327
+ {hasDiffContent && diffMeta && (
328
+ <Box marginLeft={2} marginTop={1}>
329
+ <MaxSizedBox maxHeight={12} truncationIndicator="... (diff truncated)">
330
+ <DiffView diff={diffMeta.diff} compact mode={diffMode} />
331
+ </MaxSizedBox>
332
+ </Box>
333
+ )}
334
+ {hasResultPreview && (
335
+ <Box marginLeft={2} marginTop={0}>
336
+ <ToolResultPreview result={toolCall.result} toolName={toolCall.name} />
337
+ </Box>
338
+ )}
339
+ </Box>
340
+ );
341
+ });
342
+
343
+ /**
344
+ * ThinkingIndicator component.
345
+ * Shows an animated spinner with "Thinking..." text while the agent is processing
346
+ * and before any streaming content has arrived.
347
+ *
348
+ * Uses StreamingIndicator for consistent styling across all streaming phases.
349
+ */
350
+ const ThinkingIndicator = memo(function ThinkingIndicator() {
351
+ // Use StreamingIndicator for consistent styling
352
+ return (
353
+ <Box marginBottom={1} paddingX={1}>
354
+ <StreamingIndicator phase="thinking" showPhaseTime narrow showCancelHint />
355
+ </Box>
356
+ );
357
+ });
358
+
359
+ /**
360
+ * ToolGroupItem component.
361
+ * Renders tool call rows inline between assistant segments.
362
+ */
363
+ interface ToolGroupItemProps {
364
+ readonly message: Message & { role: "tool_group" };
365
+ readonly accentColor: string;
366
+ readonly mutedColor: string;
367
+ readonly successColor: string;
368
+ readonly errorColor: string;
369
+ }
370
+
371
+ const ToolGroupItem = memo(function ToolGroupItem({
372
+ message,
373
+ accentColor,
374
+ mutedColor,
375
+ successColor,
376
+ errorColor,
377
+ }: ToolGroupItemProps) {
378
+ if (!message.toolCalls || message.toolCalls.length === 0) {
379
+ return <Box />;
380
+ }
381
+
382
+ return (
383
+ <Box flexDirection="column" marginBottom={1}>
384
+ <MaxSizedBox maxHeight={15} truncationIndicator="... (more tool calls)">
385
+ <Box flexDirection="column" marginLeft={2}>
386
+ {message.toolCalls.map((toolCall) => (
387
+ <InlineToolCall
388
+ key={toolCall.id}
389
+ toolCall={toolCall}
390
+ accentColor={accentColor}
391
+ mutedColor={mutedColor}
392
+ successColor={successColor}
393
+ errorColor={errorColor}
394
+ />
395
+ ))}
396
+ </Box>
397
+ </MaxSizedBox>
398
+ </Box>
399
+ );
400
+ });
401
+
402
+ /**
403
+ * Props for a single message item.
404
+ */
405
+ interface MessageItemProps {
406
+ readonly message: Message;
407
+ readonly roleColor: string;
408
+ readonly mutedColor: string;
409
+ readonly accentColor: string;
410
+ /** Color for thinking/reasoning content */
411
+ readonly thinkingColor: string;
412
+ /** Color for success indicators */
413
+ readonly successColor: string;
414
+ /** Color for error indicators */
415
+ readonly errorColor: string;
416
+ /** Whether to render inline tool calls for this message */
417
+ readonly showToolCalls?: boolean;
418
+ /** Display mode for thinking blocks */
419
+ readonly thinkingDisplayMode?: "full" | "compact";
420
+ }
421
+
422
+ /**
423
+ * Renders a single message with role icon, timestamp, and content.
424
+ * Includes optional thinking/reasoning content displayed before the main content.
425
+ * Tool calls are displayed inline with Gemini-style status indicators.
426
+ */
427
+ const MessageItem = memo(function MessageItem({
428
+ message,
429
+ roleColor,
430
+ mutedColor,
431
+ accentColor,
432
+ thinkingColor: _thinkingColor, // ThinkingBlock handles its own theming
433
+ successColor,
434
+ errorColor,
435
+ showToolCalls = true,
436
+ thinkingDisplayMode,
437
+ }: MessageItemProps) {
438
+ const icon = getRoleIcon(message.role);
439
+ const label = getRoleLabel(message.role);
440
+ const timestamp = formatTimestamp(message.timestamp);
441
+ const hasThinking = message.thinking && message.thinking.length > 0;
442
+
443
+ return (
444
+ <Box flexDirection="column" marginBottom={1}>
445
+ {/* Message header: role icon, label, and timestamp (or minimal for continuations) */}
446
+ <Box>
447
+ {message.isContinuation ? (
448
+ <Text color={mutedColor}>↳</Text>
449
+ ) : (
450
+ <Text>
451
+ {icon}{" "}
452
+ <Text color={roleColor} bold>
453
+ {label}
454
+ </Text>
455
+ <Text color={mutedColor}> • {timestamp}</Text>
456
+ {message.isStreaming && (
457
+ <Text color={mutedColor} italic>
458
+ {" "}
459
+ (streaming...)
460
+ </Text>
461
+ )}
462
+ </Text>
463
+ )}
464
+ </Box>
465
+
466
+ {/* Thinking/reasoning content (if present) - displayed before main content */}
467
+ {hasThinking && (
468
+ <ThinkingBlock
469
+ content={message.thinking ?? ""}
470
+ durationMs={message.thinkingDuration}
471
+ isStreaming={message.isStreaming && !message.isThinkingComplete}
472
+ initialCollapsed={!message.isStreaming}
473
+ persistenceId={`thinking-${message.id}`}
474
+ showCharCount
475
+ displayMode={thinkingDisplayMode}
476
+ />
477
+ )}
478
+
479
+ {/* Message content */}
480
+ <Box marginLeft={2} marginTop={0}>
481
+ <MarkdownRenderer
482
+ content={message.content || (message.isStreaming ? "" : "(empty)")}
483
+ compact
484
+ textColor={roleColor}
485
+ isStreaming={message.isStreaming}
486
+ />
487
+ </Box>
488
+
489
+ {/* Tool calls, if any - Gemini-style inline with status icons */}
490
+ {showToolCalls && message.toolCalls && message.toolCalls.length > 0 && (
491
+ <MaxSizedBox maxHeight={15} truncationIndicator="... (more tool calls)">
492
+ <Box flexDirection="column" marginLeft={2} marginTop={1}>
493
+ {message.toolCalls.map((toolCall) => (
494
+ <InlineToolCall
495
+ key={toolCall.id}
496
+ toolCall={toolCall}
497
+ accentColor={accentColor}
498
+ mutedColor={mutedColor}
499
+ successColor={successColor}
500
+ errorColor={errorColor}
501
+ />
502
+ ))}
503
+ </Box>
504
+ </MaxSizedBox>
505
+ )}
506
+ </Box>
507
+ );
508
+ });
509
+
510
+ // =============================================================================
511
+ // Main Component
512
+ // =============================================================================
513
+
514
+ /**
515
+ * MessageList displays a scrollable list of conversation messages.
516
+ *
517
+ * Features:
518
+ * - Renders completed messages in <Static> (never re-render)
519
+ * - Only pending streaming message causes re-renders
520
+ * - Auto-scrolls to bottom when new messages arrive
521
+ * - Disables auto-scroll when user scrolls up (PageUp/Up arrows)
522
+ * - Re-enables auto-scroll when user scrolls to bottom
523
+ * - Keyboard navigation (PageUp/PageDown, Home/End)
524
+ * - Optional windowed display with maxHeight
525
+ *
526
+ * Optimization pattern from Gemini CLI:
527
+ * - historyMessages → <Static> (rendered once, never re-render)
528
+ * - pendingMessage → Active re-rendering during streaming
529
+ *
530
+ * @example
531
+ * ```tsx
532
+ * // Basic usage with auto-scroll
533
+ * <MessageList messages={messages} />
534
+ *
535
+ * // Optimized usage with Static rendering
536
+ * <MessageList
537
+ * messages={messages}
538
+ * historyMessages={historyMessages}
539
+ * pendingMessage={pendingMessage}
540
+ * />
541
+ *
542
+ * // With scroll change callback
543
+ * <MessageList
544
+ * messages={messages}
545
+ * autoScroll={true}
546
+ * onScrollChange={(atBottom) => setShowNewIndicator(!atBottom)}
547
+ * />
548
+ *
549
+ * // With max height (windowed)
550
+ * <MessageList
551
+ * messages={messages}
552
+ * maxHeight={20}
553
+ * />
554
+ * ```
555
+ */
556
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex component with multiple rendering modes (virtualized, static, legacy) and scroll management
557
+ const MessageList = memo(function MessageList({
558
+ messages,
559
+ historyMessages,
560
+ pendingMessage,
561
+ isLoading = false,
562
+ autoScroll = true,
563
+ onScrollChange,
564
+ maxHeight,
565
+ useVirtualizedList = false,
566
+ estimatedItemHeight = 4,
567
+ isFocused,
568
+ scrollKeyMode = "all",
569
+ forceFollowOnInput = true,
570
+ modeConfig,
571
+ adaptive = true,
572
+ useAltBuffer = false,
573
+ enableScroll = false,
574
+ }: MessageListProps) {
575
+ const { theme } = useTheme();
576
+
577
+ // Subscribe to thinking display mode changes
578
+ const [thinkingDisplayMode, setThinkingDisplayMode] =
579
+ useState<ThinkingDisplayMode>(getThinkingDisplayMode);
580
+ useEffect(() => {
581
+ const unsubscribe = subscribeToDisplayMode(setThinkingDisplayMode);
582
+ return unsubscribe;
583
+ }, []);
584
+
585
+ // Determine if we should show the thinking indicator:
586
+ // - Agent is loading (processing/waiting)
587
+ // - AND no pending content has arrived yet
588
+ // NOTE: Hoisted here to avoid TDZ error with scrollViewportHeight calculation
589
+ const hasPendingContent = pendingMessage?.content && pendingMessage.content.length > 0;
590
+ const showThinkingIndicator = isLoading && !hasPendingContent;
591
+
592
+ // Get viewport dimensions for adaptive rendering
593
+ // FIX: Use consistent inputReserve value matching useAlternateBuffer default (7)
594
+ // This ensures height calculations are accurate:
595
+ // - Input minHeight: 5 lines (for multiline input)
596
+ // - Border: 2 lines (top + bottom)
597
+ // Total: 7 lines reserved for input area
598
+ const INPUT_RESERVE_HEIGHT = 7;
599
+ const { availableHeight, width } = useAlternateBuffer({
600
+ withViewport: true,
601
+ enabled: useAltBuffer,
602
+ inputReserve: INPUT_RESERVE_HEIGHT,
603
+ });
604
+
605
+ const estimatedContentWidth = Math.max(20, width - 24);
606
+
607
+ // Calculate total estimated content height for mode decisions
608
+ const totalContentHeight = useMemo(() => {
609
+ return messages.reduce(
610
+ (sum, msg) => sum + estimateMessageHeight(msg, { width: estimatedContentWidth }),
611
+ 0
612
+ );
613
+ }, [messages, estimatedContentWidth]);
614
+
615
+ // Mode controller for adaptive rendering decisions
616
+ const { mode, windowSize, modeReason, staticThreshold, virtualThreshold } = useModeController({
617
+ availableHeight,
618
+ totalContentHeight,
619
+ config: modeConfig,
620
+ });
621
+
622
+ const toolGroupCallIds = useMemo(() => {
623
+ const ids = new Set<string>();
624
+ for (const message of messages) {
625
+ if (message.role !== "tool_group" || !message.toolCalls) {
626
+ continue;
627
+ }
628
+ for (const call of message.toolCalls) {
629
+ ids.add(call.id);
630
+ }
631
+ }
632
+ return ids;
633
+ }, [messages]);
634
+ const shouldRenderInlineToolCalls = useCallback(
635
+ (message: Message) => {
636
+ if (message.role !== "assistant") {
637
+ return true;
638
+ }
639
+ if (!message.toolCalls || message.toolCalls.length === 0) {
640
+ return true;
641
+ }
642
+ for (const call of message.toolCalls) {
643
+ if (toolGroupCallIds.has(call.id)) {
644
+ return false;
645
+ }
646
+ }
647
+ return true;
648
+ },
649
+ [toolGroupCallIds]
650
+ );
651
+
652
+ const isStaticOutputMode = process.env.VELLUM_STATIC_OUTPUT === "1";
653
+ const shouldConstrainHeight =
654
+ !isStaticOutputMode && (useAltBuffer || (process.stdout.isTTY ?? false));
655
+
656
+ // Ref for VirtualizedList imperative control
657
+ const virtualizedListRef = useRef<VirtualizedListRef<Message>>(null);
658
+
659
+ // Guard against scroll controller <-> list feedback loops
660
+ const expectedScrollTopRef = useRef<number | null>(null);
661
+ const isApplyingControllerScrollRef = useRef(false);
662
+ const lastScrollMetricsRef = useRef({
663
+ scrollHeight: 0,
664
+ innerHeight: 0,
665
+ offsetFromBottom: 0,
666
+ });
667
+
668
+ // Normalize maxHeight - treat 0, undefined, null as "no max height"
669
+ const effectiveMaxHeight = maxHeight && maxHeight > 0 ? maxHeight : undefined;
670
+
671
+ // Determine if virtualized rendering should be used
672
+ // Either explicitly requested OR adaptive mode recommends it
673
+ const useVirtualizedListInternal = useVirtualizedList || (adaptive && mode === "virtualized");
674
+
675
+ // Compute max height based on adaptive mode or explicit prop
676
+ // When adaptive=true, use windowSize from mode controller for windowed mode only.
677
+ // When virtualized, let the layout determine the height to avoid premature truncation.
678
+ const computedMaxHeight = useMemo(() => {
679
+ if (effectiveMaxHeight !== undefined) {
680
+ // Explicit maxHeight prop takes precedence
681
+ return effectiveMaxHeight;
682
+ }
683
+ if (useVirtualizedListInternal) {
684
+ // When the layout isn't height-constrained, give VirtualizedList a fixed height
685
+ // so it doesn't expand with content and push the input/footer out of view.
686
+ if (!shouldConstrainHeight) {
687
+ return availableHeight;
688
+ }
689
+ return undefined;
690
+ }
691
+ if (!adaptive) {
692
+ return undefined;
693
+ }
694
+ if (mode !== "static") {
695
+ // Adaptive mode: use computed windowSize
696
+ return windowSize;
697
+ }
698
+ return undefined;
699
+ }, [
700
+ effectiveMaxHeight,
701
+ useVirtualizedListInternal,
702
+ shouldConstrainHeight,
703
+ availableHeight,
704
+ adaptive,
705
+ mode,
706
+ windowSize,
707
+ ]);
708
+
709
+ // FIX: Improved thinking indicator height estimation
710
+ // ThinkingIndicator includes: StreamingIndicator (1 line) + marginBottom (1) + paddingX
711
+ // When expanded, it can be larger. Use a more realistic estimate.
712
+ const THINKING_INDICATOR_HEIGHT = 3; // Conservative estimate for collapsed state
713
+ const thinkingIndicatorHeight = showThinkingIndicator ? THINKING_INDICATOR_HEIGHT : 0;
714
+
715
+ // Scroll viewport height (reserve space for inline indicators when needed)
716
+ // Minimum of 8 lines to prevent degenerate rendering cases
717
+ const MIN_SCROLL_VIEWPORT = 8;
718
+ const scrollViewportHeight = useMemo(() => {
719
+ const base = computedMaxHeight ?? availableHeight ?? 20;
720
+ // FIX: Ensure we don't go negative and always have minimum viewport
721
+ return Math.max(MIN_SCROLL_VIEWPORT, Math.max(0, base - thinkingIndicatorHeight));
722
+ }, [computedMaxHeight, availableHeight, thinkingIndicatorHeight]);
723
+
724
+ // Determine if we're using optimized Static rendering
725
+ // Static mode when: adaptive mode says static OR adaptive is disabled AND no explicit maxHeight
726
+ // NOTE: Static rendering does not support windowed scrolling; fall back when maxHeight is set.
727
+ // T-VIRTUAL-SCROLL: Removed hasToolGroups check - tool groups work fine with Static rendering
728
+ // as they are processed separately during message rendering.
729
+ //
730
+ // FIX: Improved thinking block handling - only disable Static mode for ACTIVELY STREAMING
731
+ // thinking blocks, not all completed thinking blocks. Completed thinking blocks have stable
732
+ // heights (collapsed by default) and work fine with Static.
733
+ // This significantly improves performance for conversations with thinking content.
734
+ const hasActiveStreamingThinking = useMemo(() => {
735
+ // Only disable Static if there's currently streaming thinking content
736
+ // that could change height during rendering
737
+ if (
738
+ pendingMessage?.thinking &&
739
+ pendingMessage.isStreaming &&
740
+ !pendingMessage.isThinkingComplete
741
+ ) {
742
+ return true;
743
+ }
744
+ // History messages with thinking are safe (collapsed by default, stable height)
745
+ return false;
746
+ }, [pendingMessage?.thinking, pendingMessage?.isStreaming, pendingMessage?.isThinkingComplete]);
747
+
748
+ const useStaticRendering =
749
+ historyMessages !== undefined &&
750
+ !computedMaxHeight &&
751
+ !hasActiveStreamingThinking &&
752
+ (mode === "static" || !adaptive);
753
+
754
+ // ==========================================================================
755
+ // New Scroll Controller (enableScroll=true)
756
+ // ==========================================================================
757
+ // When enableScroll is true, use the new follow/manual scroll system with
758
+ // ScrollIndicator and NewMessagesBadge components.
759
+
760
+ // Track previous message count for new message notification
761
+ const prevMessageLengthRef = useRef(messages.length);
762
+
763
+ // Scroll controller for follow/manual modes
764
+ const [scrollState, scrollActions] = useScrollController({
765
+ viewportHeight: scrollViewportHeight,
766
+ initialTotalHeight: totalContentHeight,
767
+ scrollStep: 3,
768
+ autoFollowOnBottom: true,
769
+ });
770
+
771
+ // Show badge when user has scrolled up and new messages arrived
772
+ const showNewMessagesBadge =
773
+ enableScroll && scrollState.mode === "manual" && scrollState.newMessageCount > 0;
774
+
775
+ // Update scroll controller when content height changes
776
+ useEffect(() => {
777
+ if (!enableScroll) return;
778
+ scrollActions.setTotalHeight(totalContentHeight);
779
+ }, [enableScroll, totalContentHeight, scrollActions]);
780
+
781
+ // Update scroll controller when viewport height changes
782
+ useEffect(() => {
783
+ if (!enableScroll) return;
784
+ scrollActions.setViewportHeight(scrollViewportHeight);
785
+ }, [enableScroll, scrollViewportHeight, scrollActions]);
786
+
787
+ // Notify new messages when in manual mode
788
+ useEffect(() => {
789
+ if (!enableScroll) return;
790
+ const newLength = messages.length;
791
+ const prevLength = prevMessageLengthRef.current;
792
+ prevMessageLengthRef.current = newLength;
793
+
794
+ if (newLength > prevLength && scrollState.mode === "manual") {
795
+ scrollActions.notifyNewMessage();
796
+ }
797
+ }, [enableScroll, messages.length, scrollState.mode, scrollActions]);
798
+
799
+ // Keyboard scroll handling (only when enableScroll is active and focused)
800
+ useKeyboardScroll({
801
+ state: scrollState,
802
+ actions: scrollActions,
803
+ enabled: enableScroll && isFocused !== false,
804
+ vimKeys: true,
805
+ });
806
+
807
+ // Animated scrollbar colors for visual feedback during scroll activity
808
+ // Provides fade-in/fade-out effect when scrolling occurs
809
+ const { scrollbarColor: animatedThumbColor, trackColor: animatedTrackColor } =
810
+ useAnimatedScrollbar(isFocused !== false, (delta) => {
811
+ // Use scrollUp/scrollDown since scrollBy doesn't exist on ViewportScrollActions
812
+ if (delta > 0) {
813
+ scrollActions.scrollDown(Math.abs(delta));
814
+ } else if (delta < 0) {
815
+ scrollActions.scrollUp(Math.abs(delta));
816
+ }
817
+ });
818
+
819
+ // Debug: log rendering mode changes (only in development)
820
+ useEffect(() => {
821
+ if (DEBUG_TUI) {
822
+ console.error("[MessageList]", {
823
+ mode,
824
+ modeReason,
825
+ totalContentHeight,
826
+ availableHeight,
827
+ windowSize,
828
+ staticThreshold,
829
+ virtualThreshold,
830
+ computedMaxHeight,
831
+ useStaticRendering,
832
+ useVirtualizedListInternal,
833
+ messageCount: messages.length,
834
+ adaptive,
835
+ });
836
+ }
837
+ }, [
838
+ mode,
839
+ modeReason,
840
+ totalContentHeight,
841
+ availableHeight,
842
+ windowSize,
843
+ staticThreshold,
844
+ virtualThreshold,
845
+ computedMaxHeight,
846
+ useStaticRendering,
847
+ useVirtualizedListInternal,
848
+ messages.length,
849
+ adaptive,
850
+ ]);
851
+
852
+ // Current scroll position (index of the first visible message in windowed mode)
853
+ const [scrollOffset, setScrollOffset] = useState(0);
854
+
855
+ // Whether user has manually scrolled away from bottom
856
+ const [userScrolledUp, setUserScrolledUp] = useState(false);
857
+
858
+ // Track previous message count for auto-scroll detection
859
+ const prevMessageCountRef = useRef(messages.length);
860
+
861
+ // Whether we're currently at the bottom of the list
862
+ const isAtBottom = useMemo(() => {
863
+ if (!computedMaxHeight || messages.length <= computedMaxHeight) {
864
+ return true;
865
+ }
866
+ return scrollOffset >= messages.length - computedMaxHeight;
867
+ }, [scrollOffset, messages.length, computedMaxHeight]);
868
+
869
+ // Calculate visible messages for windowed display (legacy mode and auto-windowed mode)
870
+ const visibleMessages = useMemo(() => {
871
+ if (!computedMaxHeight || messages.length <= computedMaxHeight) {
872
+ return messages;
873
+ }
874
+ const start = Math.max(0, Math.min(scrollOffset, messages.length - computedMaxHeight));
875
+ return messages.slice(start, start + computedMaxHeight);
876
+ }, [messages, computedMaxHeight, scrollOffset]);
877
+
878
+ // Auto-scroll to bottom when new messages arrive
879
+ useEffect(() => {
880
+ const messageCountChanged = messages.length !== prevMessageCountRef.current;
881
+ prevMessageCountRef.current = messages.length;
882
+
883
+ if (messageCountChanged && autoScroll && !userScrolledUp) {
884
+ // New message arrived and auto-scroll is enabled
885
+ if (computedMaxHeight && messages.length > computedMaxHeight) {
886
+ setScrollOffset(messages.length - computedMaxHeight);
887
+ }
888
+ }
889
+ }, [messages.length, autoScroll, userScrolledUp, computedMaxHeight]);
890
+
891
+ // Notify parent of scroll position changes
892
+ useEffect(() => {
893
+ onScrollChange?.(isAtBottom);
894
+ }, [isAtBottom, onScrollChange]);
895
+
896
+ // Scroll to bottom helper
897
+ const scrollToBottom = useCallback(() => {
898
+ if (useVirtualizedListInternal) {
899
+ virtualizedListRef.current?.scrollToEnd();
900
+ setUserScrolledUp(false);
901
+ return;
902
+ }
903
+ if (computedMaxHeight && messages.length > computedMaxHeight) {
904
+ setScrollOffset(messages.length - computedMaxHeight);
905
+ }
906
+ setUserScrolledUp(false);
907
+ }, [useVirtualizedListInternal, messages.length, computedMaxHeight]);
908
+
909
+ // Scroll up helper
910
+ const scrollUp = useCallback((amount = 1) => {
911
+ setScrollOffset((prev) => Math.max(0, prev - amount));
912
+ setUserScrolledUp(true);
913
+ }, []);
914
+
915
+ // Scroll down helper
916
+ const scrollDown = useCallback(
917
+ (amount = 1) => {
918
+ if (!computedMaxHeight || messages.length <= computedMaxHeight) return;
919
+
920
+ const maxOffset = messages.length - computedMaxHeight;
921
+ setScrollOffset((prev) => {
922
+ const newOffset = Math.min(maxOffset, prev + amount);
923
+ // If we've scrolled to the bottom, re-enable auto-scroll
924
+ if (newOffset >= maxOffset) {
925
+ setUserScrolledUp(false);
926
+ }
927
+ return newOffset;
928
+ });
929
+ },
930
+ [messages.length, computedMaxHeight]
931
+ );
932
+
933
+ // Helper: Handle virtualized list keyboard navigation
934
+ const handleVirtualizedNavigation = useCallback(
935
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex keyboard navigation with many key combinations
936
+ (input: string, key: Key, list: VirtualizedListRef<Message>): boolean => {
937
+ const scrollState = list.getScrollState();
938
+ if (scrollState.scrollHeight <= scrollState.innerHeight) {
939
+ return false;
940
+ }
941
+
942
+ const pageSize = Math.max(1, Math.floor(scrollState.innerHeight / 2));
943
+ const lineStep = 1;
944
+
945
+ if (scrollKeyMode === "page") {
946
+ if (key.pageUp) {
947
+ list.scrollBy(-pageSize);
948
+ setUserScrolledUp(true);
949
+ return true;
950
+ }
951
+
952
+ if (key.pageDown) {
953
+ const reachesBottom =
954
+ scrollState.scrollTop + pageSize >=
955
+ scrollState.scrollHeight - scrollState.innerHeight - 1;
956
+ if (reachesBottom) {
957
+ list.scrollToEnd();
958
+ setUserScrolledUp(false);
959
+ } else {
960
+ list.scrollBy(pageSize);
961
+ }
962
+ return true;
963
+ }
964
+
965
+ return false;
966
+ }
967
+
968
+ if (key.pageUp) {
969
+ list.scrollBy(-pageSize);
970
+ setUserScrolledUp(true);
971
+ return true;
972
+ }
973
+
974
+ if (key.pageDown) {
975
+ const reachesBottom =
976
+ scrollState.scrollTop + pageSize >=
977
+ scrollState.scrollHeight - scrollState.innerHeight - 1;
978
+ if (reachesBottom) {
979
+ list.scrollToEnd();
980
+ setUserScrolledUp(false);
981
+ } else {
982
+ list.scrollBy(pageSize);
983
+ }
984
+ return true;
985
+ }
986
+
987
+ if (key.upArrow) {
988
+ list.scrollBy(-lineStep);
989
+ setUserScrolledUp(true);
990
+ return true;
991
+ }
992
+
993
+ if (key.downArrow) {
994
+ const reachesBottom =
995
+ scrollState.scrollTop + lineStep >=
996
+ scrollState.scrollHeight - scrollState.innerHeight - 1;
997
+ if (reachesBottom) {
998
+ list.scrollToEnd();
999
+ setUserScrolledUp(false);
1000
+ } else {
1001
+ list.scrollBy(lineStep);
1002
+ }
1003
+ return true;
1004
+ }
1005
+
1006
+ if (isHomeKey(input)) {
1007
+ list.scrollTo(0);
1008
+ setUserScrolledUp(true);
1009
+ return true;
1010
+ }
1011
+
1012
+ if (isEndKey(input)) {
1013
+ list.scrollToEnd();
1014
+ setUserScrolledUp(false);
1015
+ return true;
1016
+ }
1017
+
1018
+ if (key.meta && key.upArrow) {
1019
+ list.scrollTo(0);
1020
+ setUserScrolledUp(true);
1021
+ return true;
1022
+ }
1023
+
1024
+ if (key.meta && key.downArrow) {
1025
+ list.scrollToEnd();
1026
+ setUserScrolledUp(false);
1027
+ return true;
1028
+ }
1029
+
1030
+ return false;
1031
+ },
1032
+ [scrollKeyMode]
1033
+ );
1034
+
1035
+ // Helper: Handle page-only scroll mode for direct navigation
1036
+ const handleDirectPageScroll = useCallback(
1037
+ (key: Key, pageStep: number): boolean => {
1038
+ if (key.pageUp) {
1039
+ scrollUp(pageStep);
1040
+ return true;
1041
+ }
1042
+ if (key.pageDown) {
1043
+ scrollDown(pageStep);
1044
+ return true;
1045
+ }
1046
+ return false;
1047
+ },
1048
+ [scrollUp, scrollDown]
1049
+ );
1050
+
1051
+ // Helper: Handle arrow key navigation for direct mode
1052
+ const handleDirectArrowScroll = useCallback(
1053
+ (key: Key, pageStep: number): boolean => {
1054
+ if (key.pageUp) {
1055
+ scrollUp(pageStep);
1056
+ return true;
1057
+ }
1058
+ if (key.pageDown) {
1059
+ scrollDown(pageStep);
1060
+ return true;
1061
+ }
1062
+ if (key.upArrow && !key.meta) {
1063
+ scrollUp(1);
1064
+ return true;
1065
+ }
1066
+ if (key.downArrow && !key.meta) {
1067
+ scrollDown(1);
1068
+ return true;
1069
+ }
1070
+ return false;
1071
+ },
1072
+ [scrollUp, scrollDown]
1073
+ );
1074
+
1075
+ // Helper: Handle meta key combinations for direct navigation
1076
+ const handleDirectMetaScroll = useCallback(
1077
+ (key: Key): boolean => {
1078
+ if (key.meta && key.upArrow) {
1079
+ setScrollOffset(0);
1080
+ setUserScrolledUp(true);
1081
+ return true;
1082
+ }
1083
+ if (key.meta && key.downArrow) {
1084
+ scrollToBottom();
1085
+ return true;
1086
+ }
1087
+ return false;
1088
+ },
1089
+ [scrollToBottom]
1090
+ );
1091
+
1092
+ // Helper: Handle direct (non-virtualized) keyboard navigation
1093
+ const handleDirectNavigation = useCallback(
1094
+ (key: Key): boolean => {
1095
+ if (!computedMaxHeight || messages.length <= computedMaxHeight) {
1096
+ return false;
1097
+ }
1098
+
1099
+ const pageStep = Math.max(1, Math.floor(computedMaxHeight / 2));
1100
+
1101
+ // Page-only mode: only handle PageUp/PageDown
1102
+ if (scrollKeyMode === "page") {
1103
+ return handleDirectPageScroll(key, pageStep);
1104
+ }
1105
+
1106
+ // Full mode: handle arrows first, then meta combinations
1107
+ if (handleDirectArrowScroll(key, pageStep)) {
1108
+ return true;
1109
+ }
1110
+ return handleDirectMetaScroll(key);
1111
+ },
1112
+ [
1113
+ computedMaxHeight,
1114
+ messages.length,
1115
+ scrollKeyMode,
1116
+ handleDirectPageScroll,
1117
+ handleDirectArrowScroll,
1118
+ handleDirectMetaScroll,
1119
+ ]
1120
+ );
1121
+
1122
+ // Helper: Detect if key is a scroll navigation key
1123
+ const isScrollNavigationKey = useCallback(
1124
+ (char: string, key: Key): boolean => {
1125
+ if (scrollKeyMode === "page") {
1126
+ return key.pageUp || key.pageDown;
1127
+ }
1128
+ return (
1129
+ key.pageUp ||
1130
+ key.pageDown ||
1131
+ key.upArrow ||
1132
+ key.downArrow ||
1133
+ isHomeKey(char) ||
1134
+ isEndKey(char) ||
1135
+ (key.meta && key.upArrow) ||
1136
+ (key.meta && key.downArrow)
1137
+ );
1138
+ },
1139
+ [scrollKeyMode]
1140
+ );
1141
+
1142
+ // Helper: Handle input when using scroll controller with virtualized list
1143
+ const handleScrollControllerInput = useCallback(
1144
+ (char: string, key: Key): boolean => {
1145
+ if (isScrollNavigationKey(char, key)) {
1146
+ return true; // Key handled by scroll controller
1147
+ }
1148
+ if (forceFollowOnInput && scrollState.mode === "manual") {
1149
+ scrollActions.scrollToBottom();
1150
+ }
1151
+ return true;
1152
+ },
1153
+ [isScrollNavigationKey, forceFollowOnInput, scrollState.mode, scrollActions]
1154
+ );
1155
+
1156
+ // Handle keyboard input for scrolling
1157
+ // isActive defaults to true when isFocused is undefined (backward compatible)
1158
+ useInput(
1159
+ useCallback(
1160
+ (char, key) => {
1161
+ // Handle scroll controller mode separately
1162
+ if (enableScroll && useVirtualizedListInternal) {
1163
+ handleScrollControllerInput(char, key);
1164
+ return;
1165
+ }
1166
+
1167
+ // Force follow on non-scroll input when user scrolled up
1168
+ const isScrollKey = isScrollNavigationKey(char, key);
1169
+ if (forceFollowOnInput && userScrolledUp && !isScrollKey) {
1170
+ scrollToBottom();
1171
+ return;
1172
+ }
1173
+
1174
+ // Delegate to appropriate navigation handler
1175
+ if (useVirtualizedListInternal) {
1176
+ const list = virtualizedListRef.current;
1177
+ if (list) {
1178
+ handleVirtualizedNavigation(char, key, list);
1179
+ }
1180
+ } else {
1181
+ handleDirectNavigation(key);
1182
+ }
1183
+ },
1184
+ [
1185
+ enableScroll,
1186
+ useVirtualizedListInternal,
1187
+ handleScrollControllerInput,
1188
+ isScrollNavigationKey,
1189
+ forceFollowOnInput,
1190
+ userScrolledUp,
1191
+ scrollToBottom,
1192
+ handleVirtualizedNavigation,
1193
+ handleDirectNavigation,
1194
+ ]
1195
+ ),
1196
+ { isActive: isFocused !== false }
1197
+ );
1198
+
1199
+ // Theme-based styling
1200
+ const roleColors: Record<Message["role"], string> = useMemo(
1201
+ () => ({
1202
+ user: theme.semantic.text.role.user,
1203
+ assistant: theme.semantic.text.role.assistant,
1204
+ system: theme.semantic.text.role.system,
1205
+ tool: theme.semantic.text.role.tool,
1206
+ tool_group: theme.semantic.text.role.tool,
1207
+ }),
1208
+ [
1209
+ theme.semantic.text.role.user,
1210
+ theme.semantic.text.role.assistant,
1211
+ theme.semantic.text.role.system,
1212
+ theme.semantic.text.role.tool,
1213
+ ]
1214
+ );
1215
+ const mutedColor = theme.semantic.text.muted;
1216
+ const accentColor = theme.colors.accent;
1217
+ const borderColor = theme.semantic.border.default;
1218
+ const successColor = theme.colors.success;
1219
+ const errorColor = theme.colors.error;
1220
+ // Use muted color for thinking/reasoning content (dimmed appearance)
1221
+ const thinkingColor = theme.semantic.text.secondary ?? mutedColor;
1222
+
1223
+ // Virtualized list callbacks must be defined unconditionally to keep hook order stable.
1224
+ const renderMessageItem = useCallback(
1225
+ ({ item }: { item: Message; index: number }) => {
1226
+ if (item.role === "tool_group") {
1227
+ return (
1228
+ <ToolGroupItem
1229
+ message={item as Message & { role: "tool_group" }}
1230
+ accentColor={accentColor}
1231
+ mutedColor={mutedColor}
1232
+ successColor={successColor}
1233
+ errorColor={errorColor}
1234
+ />
1235
+ );
1236
+ }
1237
+ const showToolCallsForItem = shouldRenderInlineToolCalls(item);
1238
+ // Standard message rendering
1239
+ return (
1240
+ <MessageItem
1241
+ message={item}
1242
+ roleColor={roleColors[item.role]}
1243
+ mutedColor={mutedColor}
1244
+ accentColor={accentColor}
1245
+ thinkingColor={thinkingColor}
1246
+ successColor={successColor}
1247
+ errorColor={errorColor}
1248
+ showToolCalls={showToolCallsForItem}
1249
+ thinkingDisplayMode={thinkingDisplayMode}
1250
+ />
1251
+ );
1252
+ },
1253
+ [
1254
+ roleColors,
1255
+ mutedColor,
1256
+ accentColor,
1257
+ thinkingColor,
1258
+ successColor,
1259
+ errorColor,
1260
+ shouldRenderInlineToolCalls,
1261
+ thinkingDisplayMode,
1262
+ ]
1263
+ );
1264
+
1265
+ const keyExtractor = useCallback((item: Message) => item.id, []);
1266
+
1267
+ const handleStickingChange = useCallback(
1268
+ (isSticking: boolean) => {
1269
+ setUserScrolledUp(!isSticking);
1270
+ onScrollChange?.(isSticking);
1271
+ },
1272
+ [onScrollChange]
1273
+ );
1274
+
1275
+ // Track sync direction to prevent circular updates
1276
+ // FIX: Enhanced circular sync prevention with debouncing and direction tracking
1277
+ const syncDirectionRef = useRef<"toController" | "toList" | null>(null);
1278
+ const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1279
+
1280
+ // Sync VirtualizedList scroll position into scroll controller (for ScrollIndicator)
1281
+ const handleVirtualizedScrollTopChange = useCallback(
1282
+ (nextScrollTop: number) => {
1283
+ if (!enableScroll || !useVirtualizedListInternal) return;
1284
+ const list = virtualizedListRef.current;
1285
+ if (!list) return;
1286
+
1287
+ // FIX: Skip if we're currently syncing from controller to list
1288
+ if (syncDirectionRef.current === "toList") {
1289
+ return;
1290
+ }
1291
+
1292
+ const expectedScrollTop = expectedScrollTopRef.current;
1293
+ if (isApplyingControllerScrollRef.current && expectedScrollTop !== null) {
1294
+ if (Math.abs(nextScrollTop - expectedScrollTop) <= 2) {
1295
+ isApplyingControllerScrollRef.current = false;
1296
+ expectedScrollTopRef.current = null;
1297
+ return;
1298
+ }
1299
+ }
1300
+
1301
+ const { scrollHeight, innerHeight } = list.getScrollState();
1302
+ const maxOffset = Math.max(0, scrollHeight - innerHeight);
1303
+ const offsetFromBottom = Math.max(0, maxOffset - nextScrollTop);
1304
+
1305
+ const lastMetrics = lastScrollMetricsRef.current;
1306
+ // FIX: Increased tolerance to prevent micro-updates causing loops
1307
+ if (
1308
+ scrollHeight === lastMetrics.scrollHeight &&
1309
+ innerHeight === lastMetrics.innerHeight &&
1310
+ Math.abs(offsetFromBottom - lastMetrics.offsetFromBottom) <= 2
1311
+ ) {
1312
+ return;
1313
+ }
1314
+
1315
+ lastScrollMetricsRef.current = { scrollHeight, innerHeight, offsetFromBottom };
1316
+
1317
+ // Mark sync direction and clear after a frame
1318
+ syncDirectionRef.current = "toController";
1319
+ if (syncTimeoutRef.current) {
1320
+ clearTimeout(syncTimeoutRef.current);
1321
+ }
1322
+ syncTimeoutRef.current = setTimeout(() => {
1323
+ syncDirectionRef.current = null;
1324
+ }, 16); // One frame at 60fps
1325
+
1326
+ scrollActions.setTotalHeight(scrollHeight);
1327
+ scrollActions.setViewportHeight(innerHeight);
1328
+ scrollActions.jumpTo(offsetFromBottom);
1329
+ },
1330
+ [enableScroll, useVirtualizedListInternal, scrollActions]
1331
+ );
1332
+
1333
+ // Apply scroll controller state to VirtualizedList (keyboard/manual scroll)
1334
+ useEffect(() => {
1335
+ if (!enableScroll || !useVirtualizedListInternal) return;
1336
+ const list = virtualizedListRef.current;
1337
+ if (!list) return;
1338
+
1339
+ // FIX: Skip if we're currently syncing from list to controller
1340
+ if (syncDirectionRef.current === "toController") {
1341
+ return;
1342
+ }
1343
+
1344
+ const { scrollHeight, innerHeight, scrollTop } = list.getScrollState();
1345
+ const maxOffset = Math.max(0, scrollHeight - innerHeight);
1346
+ const targetScrollTop = Math.max(0, maxOffset - scrollState.offsetFromBottom);
1347
+
1348
+ // FIX: Increased tolerance to prevent jitter from small differences
1349
+ if (Math.abs(scrollTop - targetScrollTop) > 2) {
1350
+ // Mark sync direction
1351
+ syncDirectionRef.current = "toList";
1352
+ if (syncTimeoutRef.current) {
1353
+ clearTimeout(syncTimeoutRef.current);
1354
+ }
1355
+ syncTimeoutRef.current = setTimeout(() => {
1356
+ syncDirectionRef.current = null;
1357
+ }, 16);
1358
+
1359
+ isApplyingControllerScrollRef.current = true;
1360
+ expectedScrollTopRef.current = targetScrollTop;
1361
+ list.scrollTo(targetScrollTop);
1362
+ }
1363
+ }, [enableScroll, useVirtualizedListInternal, scrollState.offsetFromBottom]);
1364
+
1365
+ // IMPORTANT: pendingMessage is merged into allMessages to avoid Ink <Static>
1366
+ // layout issues. Ink's <Static> doesn't participate in Flexbox layout and
1367
+ // renders at top, causing position problems when rendered separately.
1368
+ // NOTE: This useMemo MUST be before early return to satisfy React hooks rules.
1369
+ const allMessages = useMemo(() => {
1370
+ const msgs = messages as Message[];
1371
+ if (!pendingMessage) {
1372
+ return msgs;
1373
+ }
1374
+
1375
+ // Only append pendingMessage if its ID is not already in messages.
1376
+ // This avoids creating new array references when content streams.
1377
+ const existsInMessages = msgs.some((m) => m.id === pendingMessage.id);
1378
+ if (existsInMessages) {
1379
+ return msgs;
1380
+ }
1381
+ return [...msgs, pendingMessage];
1382
+ }, [messages, pendingMessage]);
1383
+
1384
+ const estimatedItemHeightForVirtualization = useMemo(() => {
1385
+ if (typeof estimatedItemHeight === "function") {
1386
+ return estimatedItemHeight;
1387
+ }
1388
+ const baseEstimate = estimatedItemHeight;
1389
+ return (index: number) => {
1390
+ const message = allMessages[index];
1391
+ if (!message) {
1392
+ return baseEstimate;
1393
+ }
1394
+ const includeToolCalls = shouldRenderInlineToolCalls(message);
1395
+ return Math.max(
1396
+ baseEstimate,
1397
+ estimateMessageHeightLegacy(message, estimatedContentWidth, includeToolCalls)
1398
+ );
1399
+ };
1400
+ }, [estimatedItemHeight, allMessages, estimatedContentWidth, shouldRenderInlineToolCalls]);
1401
+
1402
+ // Auto-scroll to end when new messages arrive or pending content updates (virtualized mode only)
1403
+ const allMessagesLengthRef = useRef(allMessages.length);
1404
+ const prevPendingContentRef = useRef<string | undefined>(pendingMessage?.content);
1405
+ const prevPendingIdRef = useRef<string | undefined>(pendingMessage?.id);
1406
+ useEffect(() => {
1407
+ const hasNewMessages = allMessages.length > allMessagesLengthRef.current;
1408
+ const pendingContentChanged = pendingMessage?.content !== prevPendingContentRef.current;
1409
+ // Detect when a NEW assistant message starts streaming (different ID than before)
1410
+ const newPendingMessageStarted =
1411
+ pendingMessage?.id !== prevPendingIdRef.current && pendingMessage?.isStreaming;
1412
+
1413
+ allMessagesLengthRef.current = allMessages.length;
1414
+ prevPendingContentRef.current = pendingMessage?.content;
1415
+ prevPendingIdRef.current = pendingMessage?.id;
1416
+
1417
+ // Reset userScrolledUp when a new assistant message starts streaming
1418
+ // This ensures auto-scroll resumes for new responses even if user scrolled up previously
1419
+ if (newPendingMessageStarted && autoScroll) {
1420
+ setUserScrolledUp(false);
1421
+ }
1422
+
1423
+ // Scroll when: new message arrived OR pending message content is streaming
1424
+ const shouldScroll = hasNewMessages || (pendingMessage?.isStreaming && pendingContentChanged);
1425
+
1426
+ // Skip scroll if user manually scrolled up (but not if we just reset it above)
1427
+ if (
1428
+ !useVirtualizedListInternal ||
1429
+ !autoScroll ||
1430
+ (userScrolledUp && !newPendingMessageStarted) ||
1431
+ !shouldScroll
1432
+ ) {
1433
+ return;
1434
+ }
1435
+ virtualizedListRef.current?.scrollToEnd();
1436
+ }, [
1437
+ useVirtualizedListInternal,
1438
+ autoScroll,
1439
+ userScrolledUp,
1440
+ allMessages,
1441
+ pendingMessage?.content,
1442
+ pendingMessage?.isStreaming,
1443
+ pendingMessage?.id,
1444
+ ]);
1445
+
1446
+ // Empty state
1447
+ if (allMessages.length === 0) {
1448
+ return (
1449
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
1450
+ <Text color={mutedColor} italic>
1451
+ No messages yet. Start a conversation!
1452
+ </Text>
1453
+ </Box>
1454
+ );
1455
+ }
1456
+
1457
+ // Calculate scroll indicator
1458
+ const showScrollUp = computedMaxHeight && scrollOffset > 0;
1459
+ const showScrollDown = computedMaxHeight && messages.length > computedMaxHeight && !isAtBottom;
1460
+
1461
+ // ==========================================================================
1462
+ // Virtualized Rendering (for optimal performance with large lists)
1463
+ // ==========================================================================
1464
+ // When useVirtualizedListInternal is enabled (explicitly or via adaptive mode),
1465
+ // we use VirtualizedList which only renders visible items.
1466
+ // This is ideal for very long conversations.
1467
+
1468
+ if (useVirtualizedListInternal) {
1469
+ const listHeight =
1470
+ computedMaxHeight !== undefined
1471
+ ? Math.max(1, computedMaxHeight - thinkingIndicatorHeight)
1472
+ : undefined;
1473
+
1474
+ return (
1475
+ <Box flexDirection="column" flexGrow={1} minHeight={0} height={computedMaxHeight}>
1476
+ <Box flexDirection="row" flexGrow={1} height={listHeight}>
1477
+ <Box flexDirection="column" flexGrow={1} height={listHeight}>
1478
+ <VirtualizedList
1479
+ ref={virtualizedListRef}
1480
+ data={allMessages}
1481
+ renderItem={renderMessageItem}
1482
+ keyExtractor={keyExtractor}
1483
+ estimatedItemHeight={estimatedItemHeightForVirtualization}
1484
+ initialScrollIndex={SCROLL_TO_ITEM_END}
1485
+ initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
1486
+ onScrollTopChange={handleVirtualizedScrollTopChange}
1487
+ onStickingToBottomChange={handleStickingChange}
1488
+ scrollbarThumbColor={animatedThumbColor}
1489
+ alignToBottom
1490
+ />
1491
+ </Box>
1492
+ {/* ScrollIndicator (right side) - only when enableScroll is true */}
1493
+ {enableScroll && (
1494
+ <ScrollIndicator
1495
+ totalHeight={scrollState.totalHeight}
1496
+ offsetFromBottom={scrollState.offsetFromBottom}
1497
+ viewportHeight={scrollState.viewportHeight}
1498
+ thumbColor={animatedThumbColor}
1499
+ trackColor={animatedTrackColor}
1500
+ />
1501
+ )}
1502
+ </Box>
1503
+
1504
+ {/* Thinking indicator - shows while agent is processing before first token */}
1505
+ {showThinkingIndicator && <ThinkingIndicator />}
1506
+
1507
+ {/* NewMessagesBadge - only when enableScroll and in manual mode with unread */}
1508
+ {showNewMessagesBadge && (
1509
+ <NewMessagesBadge
1510
+ count={scrollState.newMessageCount}
1511
+ onScrollToBottom={scrollActions.scrollToBottom}
1512
+ />
1513
+ )}
1514
+
1515
+ {/* Auto-scroll status indicator (legacy) */}
1516
+ {!enableScroll && userScrolledUp && autoScroll && (
1517
+ <Box justifyContent="center">
1518
+ <Text color={mutedColor} italic>
1519
+ Auto-scroll paused (scroll to bottom to resume)
1520
+ </Text>
1521
+ </Box>
1522
+ )}
1523
+ </Box>
1524
+ );
1525
+ }
1526
+
1527
+ // ==========================================================================
1528
+ // Optimized Rendering with Static (for completed messages)
1529
+ // ==========================================================================
1530
+ // When historyMessages is provided, we use Ink's <Static> for completed
1531
+ // messages. Static content is rendered once and never re-renders, which
1532
+ // dramatically improves performance during streaming.
1533
+ if (useStaticRendering && historyMessages) {
1534
+ return (
1535
+ <Box flexDirection="column" flexGrow={1}>
1536
+ {/* T-VIRTUAL-SCROLL: Removed spacer - Ink's Static component doesn't participate
1537
+ in Flexbox layout, and the spacer can cause position issues. Messages flow
1538
+ naturally from top to bottom with Static. */}
1539
+
1540
+ {/* Scroll up indicator (legacy) */}
1541
+ {!enableScroll && showScrollUp && (
1542
+ <Box justifyContent="center" borderBottom borderColor={borderColor}>
1543
+ <Text color={mutedColor}>↑ {scrollOffset} more above ↑</Text>
1544
+ </Box>
1545
+ )}
1546
+
1547
+ <Box flexDirection="row">
1548
+ <Box flexDirection="column" flexGrow={1}>
1549
+ {/* History messages - rendered in <Static>, NEVER re-render */}
1550
+ <Static items={historyMessages as Message[]}>
1551
+ {(message: Message) => (
1552
+ <Box key={message.id} paddingX={1}>
1553
+ <MessageItem
1554
+ message={message}
1555
+ roleColor={roleColors[message.role]}
1556
+ mutedColor={mutedColor}
1557
+ accentColor={accentColor}
1558
+ thinkingColor={thinkingColor}
1559
+ successColor={successColor}
1560
+ errorColor={errorColor}
1561
+ showToolCalls={shouldRenderInlineToolCalls(message)}
1562
+ thinkingDisplayMode={thinkingDisplayMode}
1563
+ />
1564
+ </Box>
1565
+ )}
1566
+ </Static>
1567
+
1568
+ {/* Pending message - this is the ONLY thing that re-renders during streaming */}
1569
+ {pendingMessage && (
1570
+ <Box paddingX={1}>
1571
+ <MessageItem
1572
+ message={pendingMessage}
1573
+ roleColor={roleColors[pendingMessage.role]}
1574
+ mutedColor={mutedColor}
1575
+ accentColor={accentColor}
1576
+ thinkingColor={thinkingColor}
1577
+ successColor={successColor}
1578
+ errorColor={errorColor}
1579
+ showToolCalls={shouldRenderInlineToolCalls(pendingMessage)}
1580
+ thinkingDisplayMode={thinkingDisplayMode}
1581
+ />
1582
+ </Box>
1583
+ )}
1584
+ </Box>
1585
+ {/* ScrollIndicator (right side) - only when enableScroll is true */}
1586
+ {enableScroll && (
1587
+ <ScrollIndicator
1588
+ totalHeight={scrollState.totalHeight}
1589
+ offsetFromBottom={scrollState.offsetFromBottom}
1590
+ viewportHeight={scrollState.viewportHeight}
1591
+ thumbColor={animatedThumbColor}
1592
+ trackColor={animatedTrackColor}
1593
+ />
1594
+ )}
1595
+ </Box>
1596
+
1597
+ {/* Thinking indicator - shows while agent is processing before first token */}
1598
+ {showThinkingIndicator && <ThinkingIndicator />}
1599
+
1600
+ {/* NewMessagesBadge - only when enableScroll and in manual mode with unread */}
1601
+ {showNewMessagesBadge && (
1602
+ <NewMessagesBadge
1603
+ count={scrollState.newMessageCount}
1604
+ onScrollToBottom={scrollActions.scrollToBottom}
1605
+ />
1606
+ )}
1607
+
1608
+ {/* Scroll down indicator (legacy) */}
1609
+ {!enableScroll && showScrollDown && (
1610
+ <Box justifyContent="center" borderTop borderColor={borderColor}>
1611
+ <Text color={mutedColor}>
1612
+ ↓ {messages.length - scrollOffset - (computedMaxHeight ?? 0)} more below ↓
1613
+ </Text>
1614
+ </Box>
1615
+ )}
1616
+
1617
+ {/* Auto-scroll status indicator when disabled by user scroll (legacy) */}
1618
+ {!enableScroll && userScrolledUp && autoScroll && (
1619
+ <Box justifyContent="center">
1620
+ <Text color={mutedColor} italic>
1621
+ Auto-scroll paused (scroll to bottom to resume)
1622
+ </Text>
1623
+ </Box>
1624
+ )}
1625
+ </Box>
1626
+ );
1627
+ }
1628
+
1629
+ // ==========================================================================
1630
+ // Legacy Rendering (when historyMessages not provided)
1631
+ // ==========================================================================
1632
+ // FIX: Removed the empty <Box flexGrow={1} /> spacer that was pushing messages up
1633
+ // and leaving large blank spaces at the bottom. Messages now flow naturally
1634
+ // from top to bottom, consistent with Static mode behavior.
1635
+ // The spacer was causing the "large blank spaces" bug reported by users.
1636
+ return (
1637
+ <Box flexDirection="column" flexGrow={1} justifyContent="flex-end">
1638
+ {/* Scroll up indicator (legacy) */}
1639
+ {!enableScroll && showScrollUp && (
1640
+ <Box justifyContent="center" borderBottom borderColor={borderColor}>
1641
+ <Text color={mutedColor}>↑ {scrollOffset} more above ↑</Text>
1642
+ </Box>
1643
+ )}
1644
+
1645
+ <Box flexDirection="row">
1646
+ {/* Messages */}
1647
+ <Box flexDirection="column" paddingX={1} flexGrow={1}>
1648
+ {visibleMessages.map((message) =>
1649
+ message.role === "tool_group" ? (
1650
+ <ToolGroupItem
1651
+ key={message.id}
1652
+ message={message as Message & { role: "tool_group" }}
1653
+ accentColor={accentColor}
1654
+ mutedColor={mutedColor}
1655
+ successColor={successColor}
1656
+ errorColor={errorColor}
1657
+ />
1658
+ ) : (
1659
+ <MessageItem
1660
+ key={message.id}
1661
+ message={message}
1662
+ roleColor={roleColors[message.role]}
1663
+ mutedColor={mutedColor}
1664
+ accentColor={accentColor}
1665
+ thinkingColor={thinkingColor}
1666
+ successColor={successColor}
1667
+ errorColor={errorColor}
1668
+ showToolCalls={shouldRenderInlineToolCalls(message)}
1669
+ thinkingDisplayMode={thinkingDisplayMode}
1670
+ />
1671
+ )
1672
+ )}
1673
+ </Box>
1674
+ {/* ScrollIndicator (right side) - only when enableScroll is true */}
1675
+ {enableScroll && (
1676
+ <ScrollIndicator
1677
+ totalHeight={scrollState.totalHeight}
1678
+ offsetFromBottom={scrollState.offsetFromBottom}
1679
+ viewportHeight={scrollState.viewportHeight}
1680
+ thumbColor={animatedThumbColor}
1681
+ trackColor={animatedTrackColor}
1682
+ />
1683
+ )}
1684
+ </Box>
1685
+
1686
+ {/* Thinking indicator - shows while agent is processing before first token */}
1687
+ {showThinkingIndicator && <ThinkingIndicator />}
1688
+
1689
+ {/* NewMessagesBadge - only when enableScroll and in manual mode with unread */}
1690
+ {showNewMessagesBadge && (
1691
+ <NewMessagesBadge
1692
+ count={scrollState.newMessageCount}
1693
+ onScrollToBottom={scrollActions.scrollToBottom}
1694
+ />
1695
+ )}
1696
+
1697
+ {/* Scroll down indicator (legacy) */}
1698
+ {!enableScroll && showScrollDown && (
1699
+ <Box justifyContent="center" borderTop borderColor={borderColor}>
1700
+ <Text color={mutedColor}>
1701
+ ↓ {messages.length - scrollOffset - (computedMaxHeight ?? 0)} more below ↓
1702
+ </Text>
1703
+ </Box>
1704
+ )}
1705
+
1706
+ {/* Auto-scroll status indicator when disabled by user scroll (legacy) */}
1707
+ {!enableScroll && userScrolledUp && autoScroll && (
1708
+ <Box justifyContent="center">
1709
+ <Text color={mutedColor} italic>
1710
+ Auto-scroll paused (scroll to bottom to resume)
1711
+ </Text>
1712
+ </Box>
1713
+ )}
1714
+ </Box>
1715
+ );
1716
+ });
1717
+
1718
+ export { MessageList };
1719
+ export default MessageList;