@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.
- package/LICENSE +21 -0
- package/README.md +411 -0
- package/__fixtures__/responses/code-generation.json +42 -0
- package/__fixtures__/responses/error-response.json +20 -0
- package/__fixtures__/responses/hello-world.json +32 -0
- package/dist/auth-6MCXESOH.js +26 -0
- package/dist/chunk-SECXJGWA.js +597 -0
- package/dist/index.js +34023 -0
- package/package.json +67 -0
- package/src/__tests__/commands.e2e.test.ts +728 -0
- package/src/__tests__/credentials.test.ts +713 -0
- package/src/__tests__/mode-e2e.test.ts +391 -0
- package/src/__tests__/tui-integration.test.tsx +1271 -0
- package/src/agents/__tests__/task-persistence.test.ts +235 -0
- package/src/agents/commands/delegate.ts +240 -0
- package/src/agents/commands/index.ts +10 -0
- package/src/agents/commands/resume.ts +335 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/task-persistence.ts +272 -0
- package/src/agents/task-resumption.ts +242 -0
- package/src/app.tsx +4737 -0
- package/src/commands/__tests__/.gitkeep +1 -0
- package/src/commands/__tests__/agents.test.ts +606 -0
- package/src/commands/__tests__/auth.test.ts +626 -0
- package/src/commands/__tests__/autocomplete.test.ts +683 -0
- package/src/commands/__tests__/batch.test.ts +287 -0
- package/src/commands/__tests__/chain-pipe-parser.test.ts +654 -0
- package/src/commands/__tests__/completion.test.ts +238 -0
- package/src/commands/__tests__/core.test.ts +363 -0
- package/src/commands/__tests__/executor.test.ts +496 -0
- package/src/commands/__tests__/exit-codes.test.ts +220 -0
- package/src/commands/__tests__/init.test.ts +243 -0
- package/src/commands/__tests__/language.test.ts +353 -0
- package/src/commands/__tests__/mode-cli.test.ts +667 -0
- package/src/commands/__tests__/model.test.ts +277 -0
- package/src/commands/__tests__/parser.test.ts +493 -0
- package/src/commands/__tests__/performance.bench.ts +380 -0
- package/src/commands/__tests__/registry.test.ts +534 -0
- package/src/commands/__tests__/resume.test.ts +449 -0
- package/src/commands/__tests__/security.test.ts +845 -0
- package/src/commands/__tests__/stream-json.test.ts +372 -0
- package/src/commands/__tests__/user-commands.test.ts +597 -0
- package/src/commands/adapters.ts +267 -0
- package/src/commands/agent.ts +395 -0
- package/src/commands/agents/generate.ts +506 -0
- package/src/commands/agents/index.ts +272 -0
- package/src/commands/agents/show.ts +271 -0
- package/src/commands/agents/validate.ts +387 -0
- package/src/commands/auth.ts +883 -0
- package/src/commands/autocomplete.ts +480 -0
- package/src/commands/batch/command.ts +388 -0
- package/src/commands/batch/executor.ts +361 -0
- package/src/commands/batch/index.ts +12 -0
- package/src/commands/commit.ts +235 -0
- package/src/commands/completion/index.ts +371 -0
- package/src/commands/condense.ts +191 -0
- package/src/commands/config.ts +344 -0
- package/src/commands/context-provider.ts +173 -0
- package/src/commands/copy.ts +329 -0
- package/src/commands/core/clear.ts +38 -0
- package/src/commands/core/exit.ts +43 -0
- package/src/commands/core/help.ts +354 -0
- package/src/commands/core/index.ts +15 -0
- package/src/commands/cost.ts +179 -0
- package/src/commands/credentials.tsx +618 -0
- package/src/commands/custom-agents/__tests__/custom-agents.test.ts +709 -0
- package/src/commands/custom-agents/create.ts +377 -0
- package/src/commands/custom-agents/export.ts +135 -0
- package/src/commands/custom-agents/import.ts +199 -0
- package/src/commands/custom-agents/index.ts +372 -0
- package/src/commands/custom-agents/info.ts +318 -0
- package/src/commands/custom-agents/list.ts +267 -0
- package/src/commands/custom-agents/validate.ts +388 -0
- package/src/commands/diff-mode.ts +241 -0
- package/src/commands/env.ts +53 -0
- package/src/commands/executor.ts +579 -0
- package/src/commands/exit-codes.ts +202 -0
- package/src/commands/index.ts +701 -0
- package/src/commands/init/index.ts +15 -0
- package/src/commands/init/prompts.ts +366 -0
- package/src/commands/init/templates/commands-readme.md +80 -0
- package/src/commands/init/templates/example-command.md +79 -0
- package/src/commands/init/templates/example-skill.md +168 -0
- package/src/commands/init/templates/example-workflow.md +101 -0
- package/src/commands/init/templates/prompts-readme.md +52 -0
- package/src/commands/init/templates/rules-readme.md +63 -0
- package/src/commands/init/templates/skills-readme.md +83 -0
- package/src/commands/init/templates/workflows-readme.md +94 -0
- package/src/commands/init.ts +391 -0
- package/src/commands/install.ts +90 -0
- package/src/commands/language.ts +191 -0
- package/src/commands/loaders/.gitkeep +1 -0
- package/src/commands/lsp.ts +199 -0
- package/src/commands/markdown-commands.ts +253 -0
- package/src/commands/mcp.ts +588 -0
- package/src/commands/memory/export.ts +341 -0
- package/src/commands/memory/index.ts +148 -0
- package/src/commands/memory/list.ts +261 -0
- package/src/commands/memory/search.ts +346 -0
- package/src/commands/memory/utils.ts +15 -0
- package/src/commands/metrics.ts +75 -0
- package/src/commands/migrate/index.ts +16 -0
- package/src/commands/migrate/prompts.ts +477 -0
- package/src/commands/mode.ts +331 -0
- package/src/commands/model.ts +298 -0
- package/src/commands/onboard.ts +205 -0
- package/src/commands/open.ts +169 -0
- package/src/commands/output/stream-json.ts +373 -0
- package/src/commands/parser/chain-parser.ts +370 -0
- package/src/commands/parser/index.ts +29 -0
- package/src/commands/parser/pipe-parser.ts +480 -0
- package/src/commands/parser.ts +588 -0
- package/src/commands/persistence.ts +355 -0
- package/src/commands/progress.ts +18 -0
- package/src/commands/prompt/index.ts +17 -0
- package/src/commands/prompt/validate.ts +621 -0
- package/src/commands/prompt-priority.ts +401 -0
- package/src/commands/registry.ts +374 -0
- package/src/commands/sandbox/index.ts +131 -0
- package/src/commands/security/index.ts +21 -0
- package/src/commands/security/input-sanitizer.ts +168 -0
- package/src/commands/security/permission-checker.ts +456 -0
- package/src/commands/security/sensitive-data.ts +350 -0
- package/src/commands/session/delete.ts +38 -0
- package/src/commands/session/export.ts +39 -0
- package/src/commands/session/index.ts +26 -0
- package/src/commands/session/list.ts +26 -0
- package/src/commands/session/resume.ts +562 -0
- package/src/commands/session/search.ts +434 -0
- package/src/commands/session/show.ts +26 -0
- package/src/commands/settings.ts +368 -0
- package/src/commands/setup.ts +23 -0
- package/src/commands/shell/index.ts +16 -0
- package/src/commands/shell/setup.ts +422 -0
- package/src/commands/shell-init.ts +50 -0
- package/src/commands/shell-integration/index.ts +194 -0
- package/src/commands/skill.ts +1220 -0
- package/src/commands/spec.ts +558 -0
- package/src/commands/status.ts +246 -0
- package/src/commands/theme.ts +211 -0
- package/src/commands/think.ts +551 -0
- package/src/commands/trust.ts +211 -0
- package/src/commands/tutorial.ts +522 -0
- package/src/commands/types.ts +512 -0
- package/src/commands/update.ts +274 -0
- package/src/commands/usage.ts +213 -0
- package/src/commands/user-commands.ts +630 -0
- package/src/commands/utils.ts +142 -0
- package/src/commands/vim.ts +152 -0
- package/src/commands/workflow.ts +257 -0
- package/src/components/header.tsx +25 -0
- package/src/components/input.tsx +25 -0
- package/src/components/message-list.tsx +32 -0
- package/src/components/status-bar.tsx +23 -0
- package/src/index.tsx +614 -0
- package/src/onboarding/__tests__/tutorial.test.ts +740 -0
- package/src/onboarding/index.ts +69 -0
- package/src/onboarding/tips/index.ts +9 -0
- package/src/onboarding/tips/tip-engine.ts +459 -0
- package/src/onboarding/tutorial/index.ts +88 -0
- package/src/onboarding/tutorial/lessons/basics.ts +151 -0
- package/src/onboarding/tutorial/lessons/index.ts +151 -0
- package/src/onboarding/tutorial/lessons/modes.ts +230 -0
- package/src/onboarding/tutorial/lessons/tools.ts +172 -0
- package/src/onboarding/tutorial/progress-tracker.ts +350 -0
- package/src/onboarding/tutorial/storage.ts +249 -0
- package/src/onboarding/tutorial/tutorial-system.ts +462 -0
- package/src/onboarding/tutorial/types.ts +310 -0
- package/src/orchestrator-singleton.ts +129 -0
- package/src/shutdown.ts +33 -0
- package/src/test/e2e/assertions.ts +267 -0
- package/src/test/e2e/fixtures.ts +204 -0
- package/src/test/e2e/harness.ts +575 -0
- package/src/test/e2e/index.ts +57 -0
- package/src/test/e2e/types.ts +228 -0
- package/src/test/fixtures/__tests__/fake-response-loader.test.ts +314 -0
- package/src/test/fixtures/fake-response-loader.ts +314 -0
- package/src/test/fixtures/index.ts +20 -0
- package/src/tui/__tests__/mcp-panel.test.tsx +82 -0
- package/src/tui/__tests__/mcp-wiring.test.tsx +78 -0
- package/src/tui/__tests__/mode-components.test.tsx +395 -0
- package/src/tui/__tests__/permission-ask-flow.test.tsx +138 -0
- package/src/tui/__tests__/sidebar-panel-data.test.tsx +148 -0
- package/src/tui/__tests__/tools-panel-hotkeys.test.tsx +41 -0
- package/src/tui/adapters/agent-adapter.ts +1008 -0
- package/src/tui/adapters/index.ts +48 -0
- package/src/tui/adapters/message-adapter.ts +315 -0
- package/src/tui/adapters/persistence-bridge.ts +331 -0
- package/src/tui/adapters/session-adapter.ts +419 -0
- package/src/tui/buffered-stdout.ts +223 -0
- package/src/tui/components/AgentProgress.tsx +424 -0
- package/src/tui/components/Banner/AsciiArt.ts +160 -0
- package/src/tui/components/Banner/Banner.tsx +355 -0
- package/src/tui/components/Banner/ShimmerContext.tsx +131 -0
- package/src/tui/components/Banner/ShimmerText.tsx +193 -0
- package/src/tui/components/Banner/TypeWriterGradient.tsx +321 -0
- package/src/tui/components/Banner/index.ts +61 -0
- package/src/tui/components/Banner/useShimmer.ts +241 -0
- package/src/tui/components/ChatView.tsx +11 -0
- package/src/tui/components/Checkpoint/CheckpointDiffView.tsx +371 -0
- package/src/tui/components/Checkpoint/SnapshotCheckpointPanel.tsx +440 -0
- package/src/tui/components/Checkpoint/index.ts +19 -0
- package/src/tui/components/CostDisplay.tsx +226 -0
- package/src/tui/components/InitErrorBanner.tsx +122 -0
- package/src/tui/components/Input/Autocomplete.tsx +603 -0
- package/src/tui/components/Input/EnhancedCommandInput.tsx +471 -0
- package/src/tui/components/Input/HighlightedText.tsx +236 -0
- package/src/tui/components/Input/MentionAutocomplete.tsx +375 -0
- package/src/tui/components/Input/TextInput.tsx +1002 -0
- package/src/tui/components/Input/__tests__/Autocomplete.test.tsx +374 -0
- package/src/tui/components/Input/__tests__/TextInput.test.tsx +241 -0
- package/src/tui/components/Input/__tests__/highlight.test.ts +219 -0
- package/src/tui/components/Input/__tests__/slash-command-utils.test.ts +104 -0
- package/src/tui/components/Input/highlight.ts +362 -0
- package/src/tui/components/Input/index.ts +36 -0
- package/src/tui/components/Input/slash-command-utils.ts +135 -0
- package/src/tui/components/Layout.tsx +432 -0
- package/src/tui/components/McpPanel.tsx +137 -0
- package/src/tui/components/MemoryPanel.tsx +448 -0
- package/src/tui/components/Messages/CodeBlock.tsx +527 -0
- package/src/tui/components/Messages/DiffView.tsx +679 -0
- package/src/tui/components/Messages/ImageReference.tsx +89 -0
- package/src/tui/components/Messages/MarkdownBlock.tsx +228 -0
- package/src/tui/components/Messages/MarkdownRenderer.tsx +498 -0
- package/src/tui/components/Messages/MessageBubble.tsx +270 -0
- package/src/tui/components/Messages/MessageList.tsx +1719 -0
- package/src/tui/components/Messages/StreamingText.tsx +216 -0
- package/src/tui/components/Messages/ThinkingBlock.tsx +408 -0
- package/src/tui/components/Messages/ToolResultPreview.tsx +243 -0
- package/src/tui/components/Messages/__tests__/CodeBlock.test.tsx +296 -0
- package/src/tui/components/Messages/__tests__/DiffView.test.tsx +239 -0
- package/src/tui/components/Messages/__tests__/MarkdownRenderer.test.tsx +303 -0
- package/src/tui/components/Messages/__tests__/MessageBubble.test.tsx +268 -0
- package/src/tui/components/Messages/__tests__/MessageList.test.tsx +324 -0
- package/src/tui/components/Messages/__tests__/StreamingText.test.tsx +215 -0
- package/src/tui/components/Messages/index.ts +25 -0
- package/src/tui/components/ModeIndicator.tsx +177 -0
- package/src/tui/components/ModeSelector.tsx +216 -0
- package/src/tui/components/ModelSelector.tsx +339 -0
- package/src/tui/components/OnboardingWizard.tsx +670 -0
- package/src/tui/components/PhaseProgressIndicator.tsx +270 -0
- package/src/tui/components/RateLimitIndicator.tsx +82 -0
- package/src/tui/components/ScreenReaderLayout.tsx +295 -0
- package/src/tui/components/SettingsPanel.tsx +643 -0
- package/src/tui/components/Sidebar/SystemStatusPanel.tsx +284 -0
- package/src/tui/components/Sidebar/index.ts +9 -0
- package/src/tui/components/Status/ModelStatusBar.tsx +270 -0
- package/src/tui/components/Status/index.ts +12 -0
- package/src/tui/components/StatusBar/AgentModeIndicator.tsx +257 -0
- package/src/tui/components/StatusBar/ContextProgress.tsx +167 -0
- package/src/tui/components/StatusBar/FileChangesIndicator.tsx +62 -0
- package/src/tui/components/StatusBar/GitIndicator.tsx +89 -0
- package/src/tui/components/StatusBar/HeaderBar.tsx +126 -0
- package/src/tui/components/StatusBar/ModelIndicator.tsx +157 -0
- package/src/tui/components/StatusBar/PersistenceStatusIndicator.tsx +210 -0
- package/src/tui/components/StatusBar/ResilienceIndicator.tsx +106 -0
- package/src/tui/components/StatusBar/SandboxIndicator.tsx +167 -0
- package/src/tui/components/StatusBar/StatusBar.tsx +368 -0
- package/src/tui/components/StatusBar/ThinkingModeIndicator.tsx +170 -0
- package/src/tui/components/StatusBar/TokenBreakdown.tsx +246 -0
- package/src/tui/components/StatusBar/TokenCounter.tsx +135 -0
- package/src/tui/components/StatusBar/TrustModeIndicator.tsx +130 -0
- package/src/tui/components/StatusBar/WorkspaceIndicator.tsx +86 -0
- package/src/tui/components/StatusBar/__tests__/AgentModeIndicator.test.tsx +193 -0
- package/src/tui/components/StatusBar/__tests__/StatusBar.test.tsx +729 -0
- package/src/tui/components/StatusBar/index.ts +60 -0
- package/src/tui/components/TipBanner.tsx +115 -0
- package/src/tui/components/TodoItem.tsx +208 -0
- package/src/tui/components/TodoPanel.tsx +455 -0
- package/src/tui/components/Tools/ApprovalQueue.tsx +407 -0
- package/src/tui/components/Tools/OptionSelector.tsx +160 -0
- package/src/tui/components/Tools/PermissionDialog.tsx +286 -0
- package/src/tui/components/Tools/ToolParams.tsx +483 -0
- package/src/tui/components/Tools/ToolsPanel.tsx +178 -0
- package/src/tui/components/Tools/__tests__/PermissionDialog.test.tsx +510 -0
- package/src/tui/components/Tools/__tests__/ToolParams.test.tsx +432 -0
- package/src/tui/components/Tools/index.ts +21 -0
- package/src/tui/components/TrustPrompt.tsx +279 -0
- package/src/tui/components/UpdateBanner.tsx +166 -0
- package/src/tui/components/VimModeIndicator.tsx +112 -0
- package/src/tui/components/backtrack/BacktrackControls.tsx +402 -0
- package/src/tui/components/backtrack/index.ts +13 -0
- package/src/tui/components/common/AutoApprovalStatus.tsx +251 -0
- package/src/tui/components/common/CostWarning.tsx +294 -0
- package/src/tui/components/common/DynamicShortcutHints.tsx +209 -0
- package/src/tui/components/common/EnhancedLoadingIndicator.tsx +305 -0
- package/src/tui/components/common/ErrorBoundary.tsx +140 -0
- package/src/tui/components/common/GradientText.tsx +224 -0
- package/src/tui/components/common/HotkeyHelpModal.tsx +193 -0
- package/src/tui/components/common/HotkeyHints.tsx +70 -0
- package/src/tui/components/common/MaxSizedBox.tsx +354 -0
- package/src/tui/components/common/NewMessagesBadge.tsx +65 -0
- package/src/tui/components/common/ProtectedFileLegend.tsx +89 -0
- package/src/tui/components/common/ScrollIndicator.tsx +160 -0
- package/src/tui/components/common/Spinner.tsx +342 -0
- package/src/tui/components/common/StreamingIndicator.tsx +316 -0
- package/src/tui/components/common/VirtualizedList/VirtualizedList.tsx +428 -0
- package/src/tui/components/common/VirtualizedList/hooks/index.ts +19 -0
- package/src/tui/components/common/VirtualizedList/hooks/useBatchedScroll.ts +64 -0
- package/src/tui/components/common/VirtualizedList/hooks/useScrollAnchor.ts +290 -0
- package/src/tui/components/common/VirtualizedList/hooks/useVirtualization.ts +340 -0
- package/src/tui/components/common/VirtualizedList/index.ts +30 -0
- package/src/tui/components/common/VirtualizedList/types.ts +107 -0
- package/src/tui/components/common/__tests__/NewMessagesBadge.test.tsx +74 -0
- package/src/tui/components/common/__tests__/ScrollIndicator.test.tsx +193 -0
- package/src/tui/components/common/index.ts +110 -0
- package/src/tui/components/index.ts +79 -0
- package/src/tui/components/session/CheckpointPanel.tsx +323 -0
- package/src/tui/components/session/RollbackDialog.tsx +169 -0
- package/src/tui/components/session/SessionItem.tsx +136 -0
- package/src/tui/components/session/SessionListPanel.tsx +252 -0
- package/src/tui/components/session/SessionPicker.tsx +449 -0
- package/src/tui/components/session/SessionPreview.tsx +240 -0
- package/src/tui/components/session/__tests__/session.test.tsx +408 -0
- package/src/tui/components/session/index.ts +28 -0
- package/src/tui/components/session/types.ts +116 -0
- package/src/tui/components/theme/__tests__/tokens.test.ts +471 -0
- package/src/tui/components/theme/index.ts +227 -0
- package/src/tui/components/theme/tokens.ts +484 -0
- package/src/tui/config/defaults.ts +134 -0
- package/src/tui/config/index.ts +17 -0
- package/src/tui/context/AnimationContext.tsx +284 -0
- package/src/tui/context/AppContext.tsx +349 -0
- package/src/tui/context/BracketedPasteContext.tsx +372 -0
- package/src/tui/context/LspContext.tsx +192 -0
- package/src/tui/context/McpContext.tsx +325 -0
- package/src/tui/context/MessagesContext.tsx +870 -0
- package/src/tui/context/OverflowContext.tsx +213 -0
- package/src/tui/context/RateLimitContext.tsx +108 -0
- package/src/tui/context/ResilienceContext.tsx +275 -0
- package/src/tui/context/RootProvider.tsx +136 -0
- package/src/tui/context/ScrollContext.tsx +331 -0
- package/src/tui/context/ToolsContext.tsx +702 -0
- package/src/tui/context/__tests__/BracketedPasteContext.test.tsx +416 -0
- package/src/tui/context/index.ts +140 -0
- package/src/tui/enterprise-integration.ts +282 -0
- package/src/tui/hooks/__tests__/useBacktrack.test.tsx +138 -0
- package/src/tui/hooks/__tests__/useBracketedPaste.test.tsx +222 -0
- package/src/tui/hooks/__tests__/useCopyMode.test.tsx +336 -0
- package/src/tui/hooks/__tests__/useHotkeys.ctrl-input.test.tsx +96 -0
- package/src/tui/hooks/__tests__/useHotkeys.test.tsx +454 -0
- package/src/tui/hooks/__tests__/useInputHistory.test.tsx +660 -0
- package/src/tui/hooks/__tests__/useLineBuffer.test.ts +295 -0
- package/src/tui/hooks/__tests__/useModeController.test.ts +137 -0
- package/src/tui/hooks/__tests__/useModeShortcuts.test.tsx +142 -0
- package/src/tui/hooks/__tests__/useScrollController.test.ts +464 -0
- package/src/tui/hooks/__tests__/useVim.test.tsx +531 -0
- package/src/tui/hooks/index.ts +252 -0
- package/src/tui/hooks/useAgentLoop.ts +712 -0
- package/src/tui/hooks/useAlternateBuffer.ts +398 -0
- package/src/tui/hooks/useAnimatedScrollbar.ts +241 -0
- package/src/tui/hooks/useBacktrack.ts +443 -0
- package/src/tui/hooks/useBracketedPaste.ts +104 -0
- package/src/tui/hooks/useCollapsible.ts +240 -0
- package/src/tui/hooks/useCopyMode.ts +382 -0
- package/src/tui/hooks/useCostSummary.ts +75 -0
- package/src/tui/hooks/useDesktopNotification.ts +414 -0
- package/src/tui/hooks/useDiffMode.ts +44 -0
- package/src/tui/hooks/useFileChangeStats.ts +110 -0
- package/src/tui/hooks/useFileSuggestions.ts +284 -0
- package/src/tui/hooks/useFlickerDetector.ts +250 -0
- package/src/tui/hooks/useGitStatus.ts +200 -0
- package/src/tui/hooks/useHotkeys.ts +579 -0
- package/src/tui/hooks/useImagePaste.ts +114 -0
- package/src/tui/hooks/useInputHighlight.ts +145 -0
- package/src/tui/hooks/useInputHistory.ts +246 -0
- package/src/tui/hooks/useKeyboardScroll.ts +209 -0
- package/src/tui/hooks/useLineBuffer.ts +356 -0
- package/src/tui/hooks/useMentionAutocomplete.ts +235 -0
- package/src/tui/hooks/useModeController.ts +167 -0
- package/src/tui/hooks/useModeShortcuts.ts +196 -0
- package/src/tui/hooks/usePermissionHandler.ts +146 -0
- package/src/tui/hooks/usePersistence.ts +480 -0
- package/src/tui/hooks/usePersistenceShortcuts.ts +225 -0
- package/src/tui/hooks/usePlaceholderRotation.ts +143 -0
- package/src/tui/hooks/useProviderStatus.ts +270 -0
- package/src/tui/hooks/useRateLimitStatus.ts +90 -0
- package/src/tui/hooks/useScreenReader.ts +315 -0
- package/src/tui/hooks/useScrollController.ts +450 -0
- package/src/tui/hooks/useScrollEventBatcher.ts +185 -0
- package/src/tui/hooks/useSidebarPanelData.ts +115 -0
- package/src/tui/hooks/useSmoothScroll.ts +202 -0
- package/src/tui/hooks/useSnapshots.ts +300 -0
- package/src/tui/hooks/useStateAndRef.ts +50 -0
- package/src/tui/hooks/useTerminalSize.ts +206 -0
- package/src/tui/hooks/useToolApprovalController.ts +91 -0
- package/src/tui/hooks/useVim.ts +334 -0
- package/src/tui/hooks/useWorkspace.ts +56 -0
- package/src/tui/i18n/__tests__/init.test.ts +278 -0
- package/src/tui/i18n/__tests__/language-config.test.ts +199 -0
- package/src/tui/i18n/__tests__/locale-detection.test.ts +250 -0
- package/src/tui/i18n/__tests__/settings-integration.test.ts +262 -0
- package/src/tui/i18n/index.ts +72 -0
- package/src/tui/i18n/init.ts +131 -0
- package/src/tui/i18n/language-config.ts +106 -0
- package/src/tui/i18n/locale-detection.ts +173 -0
- package/src/tui/i18n/settings-integration.ts +557 -0
- package/src/tui/i18n/tui-namespace.ts +538 -0
- package/src/tui/i18n/types.ts +312 -0
- package/src/tui/index.ts +43 -0
- package/src/tui/lsp-integration.ts +409 -0
- package/src/tui/metrics-integration.ts +366 -0
- package/src/tui/plugins.ts +383 -0
- package/src/tui/resilience.ts +342 -0
- package/src/tui/sandbox-integration.ts +317 -0
- package/src/tui/services/clipboard.ts +348 -0
- package/src/tui/services/fuzzy-search.ts +441 -0
- package/src/tui/services/index.ts +72 -0
- package/src/tui/services/markdown-renderer.ts +565 -0
- package/src/tui/services/open-external.ts +247 -0
- package/src/tui/services/syntax-highlighter.ts +483 -0
- package/src/tui/slash-commands.ts +12 -0
- package/src/tui/theme/index.ts +15 -0
- package/src/tui/theme/provider.tsx +206 -0
- package/src/tui/tip-integration.ts +300 -0
- package/src/tui/types/__tests__/ink-extended.test.ts +121 -0
- package/src/tui/types/ink-extended.ts +87 -0
- package/src/tui/utils/__tests__/bracketedPaste.test.ts +231 -0
- package/src/tui/utils/__tests__/heightEstimator.test.ts +157 -0
- package/src/tui/utils/__tests__/text-width.test.ts +158 -0
- package/src/tui/utils/__tests__/textSanitizer.test.ts +266 -0
- package/src/tui/utils/__tests__/ui-sizing.test.ts +169 -0
- package/src/tui/utils/bracketedPaste.ts +107 -0
- package/src/tui/utils/cursor-manager.ts +131 -0
- package/src/tui/utils/detectTerminal.ts +596 -0
- package/src/tui/utils/findLastSafeSplitPoint.ts +92 -0
- package/src/tui/utils/heightEstimator.ts +198 -0
- package/src/tui/utils/index.ts +91 -0
- package/src/tui/utils/isNarrowWidth.ts +52 -0
- package/src/tui/utils/stdoutGuard.ts +90 -0
- package/src/tui/utils/synchronized-update.ts +70 -0
- package/src/tui/utils/text-width.ts +225 -0
- package/src/tui/utils/textSanitizer.ts +225 -0
- package/src/tui/utils/textUtils.ts +114 -0
- package/src/tui/utils/ui-sizing.ts +192 -0
- package/src/tui-blessed/app.ts +160 -0
- package/src/tui-blessed/index.ts +2 -0
- package/src/tui-blessed/neo-blessed.d.ts +6 -0
- package/src/tui-blessed/test.ts +21 -0
- package/src/tui-blessed/types.ts +14 -0
- package/src/utils/icons.ts +130 -0
- package/src/utils/index.ts +33 -0
- package/src/utils/resume-hint.ts +86 -0
- package/src/version.ts +1 -0
- package/tsconfig.json +8 -0
- 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;
|