@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,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextInput Component (T009)
|
|
3
|
+
*
|
|
4
|
+
* A React Ink-based text input component with multiline support.
|
|
5
|
+
* Provides keyboard handling for text entry, navigation, and submission.
|
|
6
|
+
*
|
|
7
|
+
* @module tui/components/Input/TextInput
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Key } from "ink";
|
|
11
|
+
import { Box, Text, useInput } from "ink";
|
|
12
|
+
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
+
import { useAnimation } from "../../context/AnimationContext.js";
|
|
14
|
+
import { usePasteHandler } from "../../context/BracketedPasteContext.js";
|
|
15
|
+
import { useTheme } from "../../theme/index.js";
|
|
16
|
+
import { HighlightedText } from "./HighlightedText.js";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Props for the TextInput component.
|
|
24
|
+
*/
|
|
25
|
+
export interface TextInputProps {
|
|
26
|
+
/** Current input value (controlled) */
|
|
27
|
+
readonly value: string;
|
|
28
|
+
/** Callback when value changes */
|
|
29
|
+
readonly onChange: (value: string) => void;
|
|
30
|
+
/** Callback when input is submitted */
|
|
31
|
+
readonly onSubmit?: (value: string) => void;
|
|
32
|
+
/** Placeholder text shown when value is empty */
|
|
33
|
+
readonly placeholder?: string;
|
|
34
|
+
/** Enable multiline input mode */
|
|
35
|
+
readonly multiline?: boolean;
|
|
36
|
+
/** Disable input interactions */
|
|
37
|
+
readonly disabled?: boolean;
|
|
38
|
+
/** Maximum character length */
|
|
39
|
+
readonly maxLength?: number;
|
|
40
|
+
/** Whether the input is focused (enables keyboard handling) */
|
|
41
|
+
readonly focused?: boolean;
|
|
42
|
+
/** Minimum height in lines (default: 1 for single-line, 3 for multiline) */
|
|
43
|
+
readonly minHeight?: number;
|
|
44
|
+
/** Optional mask character for password-style input */
|
|
45
|
+
readonly mask?: string;
|
|
46
|
+
/** When true, suppress Enter from submitting (for autocomplete integration) */
|
|
47
|
+
readonly suppressEnter?: boolean;
|
|
48
|
+
/** When true, suppress Tab from inserting spaces (for autocomplete integration) */
|
|
49
|
+
readonly suppressTab?: boolean;
|
|
50
|
+
/** When true, move cursor to end of value on next render */
|
|
51
|
+
readonly cursorToEnd?: boolean;
|
|
52
|
+
/** Callback when cursorToEnd is consumed */
|
|
53
|
+
readonly onCursorMoved?: () => void;
|
|
54
|
+
/** Whether to show border in single-line mode (default: true) */
|
|
55
|
+
readonly showBorder?: boolean;
|
|
56
|
+
/** Enable syntax highlighting for @mentions, /commands, URLs, and `code` (default: false) */
|
|
57
|
+
readonly enableHighlight?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// Helper Functions
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Insert a character at a specific position in a string.
|
|
66
|
+
*/
|
|
67
|
+
function insertAt(str: string, index: number, char: string): string {
|
|
68
|
+
return str.slice(0, index) + char + str.slice(index);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Delete a character at a specific position in a string.
|
|
73
|
+
*/
|
|
74
|
+
function deleteAt(str: string, index: number): string {
|
|
75
|
+
if (index <= 0 || index > str.length) return str;
|
|
76
|
+
return str.slice(0, index - 1) + str.slice(index);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Strip common ANSI control sequences (e.g., bracketed paste wrappers).
|
|
81
|
+
*/
|
|
82
|
+
function stripAnsiSequences(input: string): string {
|
|
83
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - matching ANSI escape sequences
|
|
84
|
+
const withoutCsi = input.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
85
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - matching OSC sequences with BEL/ST terminators
|
|
86
|
+
return withoutCsi.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize and sanitize input chunks for single-line or multiline fields.
|
|
91
|
+
*/
|
|
92
|
+
function normalizeInputValue(input: string, multiline: boolean): string {
|
|
93
|
+
const sanitized = stripAnsiSequences(input)
|
|
94
|
+
.replace(/\r\n/g, "\n")
|
|
95
|
+
.replace(/\r/g, "\n")
|
|
96
|
+
.replace(/\u2028|\u2029/g, "\n");
|
|
97
|
+
|
|
98
|
+
const withoutControls = multiline
|
|
99
|
+
? // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - stripping control chars except tab/newline/CR
|
|
100
|
+
sanitized.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]/g, "")
|
|
101
|
+
: // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - stripping all control chars for single-line
|
|
102
|
+
sanitized.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, "");
|
|
103
|
+
|
|
104
|
+
return multiline ? withoutControls : withoutControls.replace(/\n/g, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate a stable key for multiline rendering.
|
|
109
|
+
* Uses line start position as key since lines can be added/removed.
|
|
110
|
+
*/
|
|
111
|
+
function getLineKey(lineStartPos: number): string {
|
|
112
|
+
return `line-${lineStartPos}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Component
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* TextInput provides a text input field for terminal UIs.
|
|
121
|
+
*
|
|
122
|
+
* Features:
|
|
123
|
+
* - Single-line mode: Enter submits
|
|
124
|
+
* - Multiline mode: Shift+Enter adds newline, Ctrl+Enter submits
|
|
125
|
+
* - Cursor navigation with arrow keys
|
|
126
|
+
* - Placeholder display when empty
|
|
127
|
+
* - Theme-aware styling
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```tsx
|
|
131
|
+
* // Single-line input
|
|
132
|
+
* <TextInput
|
|
133
|
+
* value={text}
|
|
134
|
+
* onChange={setText}
|
|
135
|
+
* onSubmit={handleSubmit}
|
|
136
|
+
* placeholder="Type a message..."
|
|
137
|
+
* />
|
|
138
|
+
*
|
|
139
|
+
* // Multiline input
|
|
140
|
+
* <TextInput
|
|
141
|
+
* value={text}
|
|
142
|
+
* onChange={setText}
|
|
143
|
+
* onSubmit={handleSubmit}
|
|
144
|
+
* placeholder="Type a message..."
|
|
145
|
+
* multiline
|
|
146
|
+
* />
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
function TextInputComponent({
|
|
150
|
+
value,
|
|
151
|
+
onChange,
|
|
152
|
+
onSubmit,
|
|
153
|
+
placeholder = "",
|
|
154
|
+
multiline = false,
|
|
155
|
+
disabled = false,
|
|
156
|
+
maxLength,
|
|
157
|
+
focused = true,
|
|
158
|
+
minHeight,
|
|
159
|
+
mask,
|
|
160
|
+
suppressEnter = false,
|
|
161
|
+
suppressTab = false,
|
|
162
|
+
cursorToEnd = false,
|
|
163
|
+
onCursorMoved,
|
|
164
|
+
showBorder = true,
|
|
165
|
+
enableHighlight = false,
|
|
166
|
+
}: TextInputProps) {
|
|
167
|
+
const { theme } = useTheme();
|
|
168
|
+
const { pauseAnimations, resumeAnimations, isVSCode } = useAnimation();
|
|
169
|
+
|
|
170
|
+
// Pause animations when input is focused in VS Code to reduce flickering
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (focused && isVSCode) {
|
|
173
|
+
pauseAnimations();
|
|
174
|
+
return () => resumeAnimations();
|
|
175
|
+
}
|
|
176
|
+
}, [focused, isVSCode, pauseAnimations, resumeAnimations]);
|
|
177
|
+
|
|
178
|
+
// Calculate effective min height (default 5 for multiline, 1 for single-line)
|
|
179
|
+
const effectiveMinHeight = minHeight ?? (multiline ? 5 : 1);
|
|
180
|
+
|
|
181
|
+
// Rapid input buffering for paste fallback (when bracketed paste is not available)
|
|
182
|
+
const inputBufferRef = useRef<string>("");
|
|
183
|
+
const inputTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
184
|
+
// Use shorter threshold for faster input response
|
|
185
|
+
// Reduced from 100/150ms to 50/80ms for improved responsiveness
|
|
186
|
+
const RAPID_INPUT_THRESHOLD = mask ? 80 : 50; // ms - reduced for faster response
|
|
187
|
+
|
|
188
|
+
// Refs to store latest value and cursorPosition for setTimeout callback
|
|
189
|
+
// This avoids closure trap where setTimeout captures stale values
|
|
190
|
+
const valueRef = useRef(value);
|
|
191
|
+
const cursorPositionRef = useRef(0);
|
|
192
|
+
|
|
193
|
+
// Cursor position within the value
|
|
194
|
+
const [cursorPosition, setCursorPosition] = useState(value.length);
|
|
195
|
+
|
|
196
|
+
// Undo/Redo stacks for Ctrl+Z support
|
|
197
|
+
const [undoStack, setUndoStack] = useState<Array<{ value: string; cursor: number }>>([]);
|
|
198
|
+
const [redoStack, setRedoStack] = useState<Array<{ value: string; cursor: number }>>([]);
|
|
199
|
+
const lastValueRef = useRef(value);
|
|
200
|
+
|
|
201
|
+
// Sync cursor position when value changes externally
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (cursorPosition > value.length) {
|
|
204
|
+
setCursorPosition(value.length);
|
|
205
|
+
}
|
|
206
|
+
}, [value, cursorPosition]);
|
|
207
|
+
|
|
208
|
+
// Keep valueRef in sync with latest value
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
valueRef.current = value;
|
|
211
|
+
}, [value]);
|
|
212
|
+
|
|
213
|
+
// Keep cursorPositionRef in sync with latest cursorPosition
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
cursorPositionRef.current = cursorPosition;
|
|
216
|
+
}, [cursorPosition]);
|
|
217
|
+
|
|
218
|
+
// Track value changes for undo stack (debounced to group rapid typing)
|
|
219
|
+
const undoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (value !== lastValueRef.current) {
|
|
222
|
+
// Debounce undo snapshots - group rapid typing into single undo entry
|
|
223
|
+
if (undoTimerRef.current) {
|
|
224
|
+
clearTimeout(undoTimerRef.current);
|
|
225
|
+
}
|
|
226
|
+
const prevValue = lastValueRef.current;
|
|
227
|
+
const prevCursor = cursorPositionRef.current;
|
|
228
|
+
undoTimerRef.current = setTimeout(() => {
|
|
229
|
+
setUndoStack((prev) => [...prev.slice(-99), { value: prevValue, cursor: prevCursor }]);
|
|
230
|
+
setRedoStack([]); // Clear redo on new changes
|
|
231
|
+
}, 300);
|
|
232
|
+
lastValueRef.current = value;
|
|
233
|
+
}
|
|
234
|
+
return () => {
|
|
235
|
+
if (undoTimerRef.current) {
|
|
236
|
+
clearTimeout(undoTimerRef.current);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}, [value]);
|
|
240
|
+
|
|
241
|
+
// Handle cursorToEnd prop - move cursor to end when requested
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (cursorToEnd) {
|
|
244
|
+
setCursorPosition(value.length);
|
|
245
|
+
onCursorMoved?.();
|
|
246
|
+
}
|
|
247
|
+
}, [cursorToEnd, value.length, onCursorMoved]);
|
|
248
|
+
|
|
249
|
+
// Cleanup input buffer timer on unmount
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
return () => {
|
|
252
|
+
if (inputTimerRef.current) {
|
|
253
|
+
clearTimeout(inputTimerRef.current);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Handle bracketed paste events.
|
|
260
|
+
* When a paste is detected via bracketed paste mode, the entire
|
|
261
|
+
* pasted content arrives as a single event instead of character-by-character.
|
|
262
|
+
*/
|
|
263
|
+
const handlePaste = useCallback(
|
|
264
|
+
(pastedText: string) => {
|
|
265
|
+
if (disabled || !focused) return;
|
|
266
|
+
|
|
267
|
+
// Normalize the pasted text
|
|
268
|
+
const normalizedPaste = normalizeInputValue(pastedText, multiline);
|
|
269
|
+
if (normalizedPaste.length === 0) return;
|
|
270
|
+
|
|
271
|
+
// Insert at cursor position
|
|
272
|
+
let newValue = insertAt(value, cursorPosition, normalizedPaste);
|
|
273
|
+
let newCursorPosition = cursorPosition + normalizedPaste.length;
|
|
274
|
+
|
|
275
|
+
// Handle max length
|
|
276
|
+
if (maxLength !== undefined && newValue.length > maxLength) {
|
|
277
|
+
newValue = newValue.slice(0, maxLength);
|
|
278
|
+
newCursorPosition = Math.min(newCursorPosition, maxLength);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
onChange(newValue);
|
|
282
|
+
setCursorPosition(newCursorPosition);
|
|
283
|
+
},
|
|
284
|
+
[disabled, focused, multiline, value, cursorPosition, maxLength, onChange]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Subscribe to paste events from the BracketedPasteProvider
|
|
288
|
+
usePasteHandler(handlePaste);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Handle character input
|
|
292
|
+
*/
|
|
293
|
+
const handleInput = useCallback(
|
|
294
|
+
(char: string) => {
|
|
295
|
+
if (disabled) return;
|
|
296
|
+
|
|
297
|
+
const normalizedInput = normalizeInputValue(char, multiline);
|
|
298
|
+
if (normalizedInput.length === 0) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check max length before inserting
|
|
303
|
+
if (maxLength !== undefined && value.length >= maxLength) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const newValue = insertAt(value, cursorPosition, normalizedInput);
|
|
308
|
+
|
|
309
|
+
// Check max length after insertion (handles paste)
|
|
310
|
+
if (maxLength !== undefined && newValue.length > maxLength) {
|
|
311
|
+
const truncated = newValue.slice(0, maxLength);
|
|
312
|
+
onChange(truncated);
|
|
313
|
+
setCursorPosition(Math.min(cursorPosition + normalizedInput.length, maxLength));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
onChange(newValue);
|
|
318
|
+
setCursorPosition(cursorPosition + normalizedInput.length);
|
|
319
|
+
},
|
|
320
|
+
[disabled, value, cursorPosition, maxLength, onChange, multiline]
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Handle backspace key
|
|
325
|
+
*/
|
|
326
|
+
const handleBackspace = useCallback(() => {
|
|
327
|
+
if (disabled || cursorPosition === 0) return;
|
|
328
|
+
|
|
329
|
+
const newValue = deleteAt(value, cursorPosition);
|
|
330
|
+
onChange(newValue);
|
|
331
|
+
setCursorPosition(cursorPosition - 1);
|
|
332
|
+
}, [disabled, value, cursorPosition, onChange]);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Handle delete key
|
|
336
|
+
*/
|
|
337
|
+
const handleDelete = useCallback(() => {
|
|
338
|
+
if (disabled || cursorPosition >= value.length) return;
|
|
339
|
+
|
|
340
|
+
const newValue = value.slice(0, cursorPosition) + value.slice(cursorPosition + 1);
|
|
341
|
+
onChange(newValue);
|
|
342
|
+
}, [disabled, value, cursorPosition, onChange]);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Handle left arrow navigation
|
|
346
|
+
*/
|
|
347
|
+
const handleLeftArrow = useCallback(
|
|
348
|
+
(ctrl: boolean) => {
|
|
349
|
+
if (cursorPosition === 0) return;
|
|
350
|
+
|
|
351
|
+
if (ctrl) {
|
|
352
|
+
// Move to previous word boundary
|
|
353
|
+
const beforeCursor = value.slice(0, cursorPosition);
|
|
354
|
+
const match = beforeCursor.match(/\s*\S*$/);
|
|
355
|
+
const jumpLength = match ? match[0].length : 1;
|
|
356
|
+
setCursorPosition(Math.max(0, cursorPosition - jumpLength));
|
|
357
|
+
} else {
|
|
358
|
+
setCursorPosition(cursorPosition - 1);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
[value, cursorPosition]
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Handle right arrow navigation
|
|
366
|
+
*/
|
|
367
|
+
const handleRightArrow = useCallback(
|
|
368
|
+
(ctrl: boolean) => {
|
|
369
|
+
if (cursorPosition >= value.length) return;
|
|
370
|
+
|
|
371
|
+
if (ctrl) {
|
|
372
|
+
// Move to next word boundary
|
|
373
|
+
const afterCursor = value.slice(cursorPosition);
|
|
374
|
+
const match = afterCursor.match(/^\S*\s*/);
|
|
375
|
+
const jumpLength = match ? match[0].length : 1;
|
|
376
|
+
setCursorPosition(Math.min(value.length, cursorPosition + jumpLength));
|
|
377
|
+
} else {
|
|
378
|
+
setCursorPosition(cursorPosition + 1);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
[value, cursorPosition]
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Handle up arrow in multiline mode
|
|
386
|
+
*/
|
|
387
|
+
const handleUpArrow = useCallback(() => {
|
|
388
|
+
if (!multiline) return;
|
|
389
|
+
|
|
390
|
+
// Find the previous newline
|
|
391
|
+
const beforeCursor = value.slice(0, cursorPosition);
|
|
392
|
+
const lastNewline = beforeCursor.lastIndexOf("\n");
|
|
393
|
+
|
|
394
|
+
if (lastNewline === -1) {
|
|
395
|
+
// No previous line, move to start
|
|
396
|
+
setCursorPosition(0);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Find the newline before that to get line start
|
|
401
|
+
const lineStart = beforeCursor.lastIndexOf("\n", lastNewline - 1) + 1;
|
|
402
|
+
const columnInCurrentLine = cursorPosition - lastNewline - 1;
|
|
403
|
+
const previousLineLength = lastNewline - lineStart;
|
|
404
|
+
|
|
405
|
+
// Move to same column in previous line (or end of line if shorter)
|
|
406
|
+
const newPosition = lineStart + Math.min(columnInCurrentLine, previousLineLength);
|
|
407
|
+
setCursorPosition(newPosition);
|
|
408
|
+
}, [multiline, value, cursorPosition]);
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Handle down arrow in multiline mode
|
|
412
|
+
*/
|
|
413
|
+
const handleDownArrow = useCallback(() => {
|
|
414
|
+
if (!multiline) return;
|
|
415
|
+
|
|
416
|
+
// Find the current line boundaries
|
|
417
|
+
const beforeCursor = value.slice(0, cursorPosition);
|
|
418
|
+
const afterCursor = value.slice(cursorPosition);
|
|
419
|
+
|
|
420
|
+
const currentLineStart = beforeCursor.lastIndexOf("\n") + 1;
|
|
421
|
+
const columnInCurrentLine = cursorPosition - currentLineStart;
|
|
422
|
+
|
|
423
|
+
const nextNewline = afterCursor.indexOf("\n");
|
|
424
|
+
if (nextNewline === -1) {
|
|
425
|
+
// No next line, move to end
|
|
426
|
+
setCursorPosition(value.length);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Find the line after the next newline
|
|
431
|
+
const nextLineStart = cursorPosition + nextNewline + 1;
|
|
432
|
+
const restAfterNextLine = value.slice(nextLineStart);
|
|
433
|
+
const nextLineEnd = restAfterNextLine.indexOf("\n");
|
|
434
|
+
const nextLineLength = nextLineEnd === -1 ? restAfterNextLine.length : nextLineEnd;
|
|
435
|
+
|
|
436
|
+
// Move to same column in next line (or end of line if shorter)
|
|
437
|
+
const newPosition = nextLineStart + Math.min(columnInCurrentLine, nextLineLength);
|
|
438
|
+
setCursorPosition(newPosition);
|
|
439
|
+
}, [multiline, value, cursorPosition]);
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Calculate current cursor row (0-indexed)
|
|
443
|
+
*/
|
|
444
|
+
const cursorRow = useMemo(() => {
|
|
445
|
+
const beforeCursor = value.slice(0, cursorPosition);
|
|
446
|
+
return (beforeCursor.match(/\n/g) || []).length;
|
|
447
|
+
}, [value, cursorPosition]);
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Handle Home key (Ctrl+A) - move to current line start
|
|
451
|
+
*/
|
|
452
|
+
const handleHome = useCallback(() => {
|
|
453
|
+
const lines = value.split("\n");
|
|
454
|
+
let pos = 0;
|
|
455
|
+
for (let i = 0; i < cursorRow; i++) {
|
|
456
|
+
const line = lines[i];
|
|
457
|
+
if (line !== undefined) {
|
|
458
|
+
pos += line.length + 1;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
setCursorPosition(pos);
|
|
462
|
+
}, [value, cursorRow]);
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Handle End key (Ctrl+E) - move to current line end
|
|
466
|
+
*/
|
|
467
|
+
const handleEnd = useCallback(() => {
|
|
468
|
+
const lines = value.split("\n");
|
|
469
|
+
let pos = 0;
|
|
470
|
+
for (let i = 0; i <= cursorRow; i++) {
|
|
471
|
+
const line = lines[i];
|
|
472
|
+
if (line !== undefined) {
|
|
473
|
+
pos += line.length + (i < cursorRow ? 1 : 0);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
setCursorPosition(pos);
|
|
477
|
+
}, [value, cursorRow]);
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Handle Ctrl+K - kill (delete) from cursor to end of line
|
|
481
|
+
*/
|
|
482
|
+
const handleKillToEnd = useCallback(() => {
|
|
483
|
+
if (disabled) return;
|
|
484
|
+
const lines = value.split("\n");
|
|
485
|
+
const currentLine = lines[cursorRow];
|
|
486
|
+
if (currentLine === undefined) return;
|
|
487
|
+
const lineStart = lines.slice(0, cursorRow).join("\n").length + (cursorRow > 0 ? 1 : 0);
|
|
488
|
+
const lineEnd = lineStart + currentLine.length;
|
|
489
|
+
const newValue = value.slice(0, cursorPosition) + value.slice(lineEnd);
|
|
490
|
+
onChange(newValue);
|
|
491
|
+
}, [disabled, value, cursorPosition, cursorRow, onChange]);
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Handle Ctrl+U - kill (delete) from cursor to start of line
|
|
495
|
+
*/
|
|
496
|
+
const handleKillToStart = useCallback(() => {
|
|
497
|
+
if (disabled) return;
|
|
498
|
+
const lines = value.split("\n");
|
|
499
|
+
const lineStart = lines.slice(0, cursorRow).join("\n").length + (cursorRow > 0 ? 1 : 0);
|
|
500
|
+
const newValue = value.slice(0, lineStart) + value.slice(cursorPosition);
|
|
501
|
+
onChange(newValue);
|
|
502
|
+
setCursorPosition(lineStart);
|
|
503
|
+
}, [disabled, value, cursorPosition, cursorRow, onChange]);
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Handle Ctrl+W - delete word backward
|
|
507
|
+
*/
|
|
508
|
+
const handleDeleteWordBackward = useCallback(() => {
|
|
509
|
+
if (disabled || cursorPosition === 0) return;
|
|
510
|
+
|
|
511
|
+
// Find the start of the previous word
|
|
512
|
+
let pos = cursorPosition - 1;
|
|
513
|
+
// Skip whitespace
|
|
514
|
+
while (pos > 0 && /\s/.test(value.charAt(pos))) pos--;
|
|
515
|
+
// Skip word characters
|
|
516
|
+
while (pos > 0 && !/\s/.test(value.charAt(pos - 1))) pos--;
|
|
517
|
+
|
|
518
|
+
const newValue = value.slice(0, pos) + value.slice(cursorPosition);
|
|
519
|
+
onChange(newValue);
|
|
520
|
+
setCursorPosition(pos);
|
|
521
|
+
}, [disabled, value, cursorPosition, onChange]);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Handle Ctrl+Z - undo
|
|
525
|
+
*/
|
|
526
|
+
const handleUndo = useCallback(() => {
|
|
527
|
+
const prev = undoStack[undoStack.length - 1];
|
|
528
|
+
if (!prev) return;
|
|
529
|
+
setUndoStack((s) => s.slice(0, -1));
|
|
530
|
+
setRedoStack((s) => [...s, { value, cursor: cursorPosition }]);
|
|
531
|
+
onChange(prev.value);
|
|
532
|
+
setCursorPosition(prev.cursor);
|
|
533
|
+
lastValueRef.current = prev.value; // Prevent re-adding to undo stack
|
|
534
|
+
}, [undoStack, value, cursorPosition, onChange]);
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Handle Ctrl+Shift+Z / Ctrl+Y - redo
|
|
538
|
+
*/
|
|
539
|
+
const handleRedo = useCallback(() => {
|
|
540
|
+
const next = redoStack[redoStack.length - 1];
|
|
541
|
+
if (!next) return;
|
|
542
|
+
setRedoStack((s) => s.slice(0, -1));
|
|
543
|
+
setUndoStack((s) => [...s, { value, cursor: cursorPosition }]);
|
|
544
|
+
onChange(next.value);
|
|
545
|
+
setCursorPosition(next.cursor);
|
|
546
|
+
lastValueRef.current = next.value; // Prevent re-adding to undo stack
|
|
547
|
+
}, [redoStack, value, cursorPosition, onChange]);
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Handle submission
|
|
551
|
+
*/
|
|
552
|
+
const handleSubmit = useCallback(() => {
|
|
553
|
+
if (disabled) return;
|
|
554
|
+
onSubmit?.(value);
|
|
555
|
+
}, [disabled, value, onSubmit]);
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Handle newline in multiline mode
|
|
559
|
+
*/
|
|
560
|
+
const handleNewline = useCallback(() => {
|
|
561
|
+
if (disabled || !multiline) return;
|
|
562
|
+
|
|
563
|
+
// Check max length before inserting newline
|
|
564
|
+
if (maxLength !== undefined && value.length >= maxLength) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const newValue = insertAt(value, cursorPosition, "\n");
|
|
569
|
+
onChange(newValue);
|
|
570
|
+
setCursorPosition(cursorPosition + 1);
|
|
571
|
+
}, [disabled, multiline, value, cursorPosition, maxLength, onChange]);
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Handle return/enter key based on mode
|
|
575
|
+
* - Shift+Enter: Always inserts newline (even in single-line mode for multiline contexts)
|
|
576
|
+
* - Enter (multiline): Inserts newline
|
|
577
|
+
* - Ctrl+Enter (multiline): Submits
|
|
578
|
+
* - Enter (single-line): Submits
|
|
579
|
+
*/
|
|
580
|
+
const handleReturn = useCallback(
|
|
581
|
+
(ctrl: boolean, shift: boolean): boolean => {
|
|
582
|
+
// Skip if Enter should be suppressed (e.g., autocomplete is active)
|
|
583
|
+
// Check prop directly for synchronous behavior - state-based check was racy
|
|
584
|
+
if (suppressEnter) {
|
|
585
|
+
return false; // Let parent handle it
|
|
586
|
+
}
|
|
587
|
+
// Shift+Enter always inserts newline (multiline mode required)
|
|
588
|
+
if (shift && multiline) {
|
|
589
|
+
handleNewline();
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
if (multiline && !ctrl) {
|
|
593
|
+
handleNewline();
|
|
594
|
+
} else {
|
|
595
|
+
handleSubmit();
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
},
|
|
599
|
+
[multiline, handleNewline, handleSubmit, suppressEnter]
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Handle tab key (multiline only)
|
|
604
|
+
* Returns true if key was handled, false to pass through
|
|
605
|
+
*/
|
|
606
|
+
const handleTab = useCallback((): boolean => {
|
|
607
|
+
// Skip if Tab should be suppressed (e.g., autocomplete is active)
|
|
608
|
+
if (suppressTab) {
|
|
609
|
+
return false; // Let parent handle it
|
|
610
|
+
}
|
|
611
|
+
if (multiline) {
|
|
612
|
+
handleInput(" ");
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
}, [multiline, handleInput, suppressTab]);
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Process a key event and dispatch to appropriate handler.
|
|
619
|
+
* Returns true if the key was handled.
|
|
620
|
+
*/
|
|
621
|
+
const processKeyEvent = useCallback(
|
|
622
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Key event handler with many key combinations
|
|
623
|
+
(input: string, key: Key): boolean => {
|
|
624
|
+
// Navigation and editing keys
|
|
625
|
+
if (key.backspace) {
|
|
626
|
+
// Ctrl+Backspace: delete word backward
|
|
627
|
+
if (key.ctrl) {
|
|
628
|
+
handleDeleteWordBackward();
|
|
629
|
+
} else {
|
|
630
|
+
handleBackspace();
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
if (key.delete) {
|
|
635
|
+
handleDelete();
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
if (key.leftArrow) {
|
|
639
|
+
handleLeftArrow(key.ctrl);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
if (key.rightArrow) {
|
|
643
|
+
handleRightArrow(key.ctrl);
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
if (key.upArrow) {
|
|
647
|
+
handleUpArrow();
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
if (key.downArrow) {
|
|
651
|
+
handleDownArrow();
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
if (key.return) {
|
|
655
|
+
return handleReturn(key.ctrl, key.shift);
|
|
656
|
+
}
|
|
657
|
+
if (key.tab) {
|
|
658
|
+
return handleTab();
|
|
659
|
+
}
|
|
660
|
+
if (key.escape) {
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Emacs-style keybindings (Ctrl+key)
|
|
665
|
+
if (key.ctrl) {
|
|
666
|
+
switch (input.toLowerCase()) {
|
|
667
|
+
case "a": // Ctrl+A: Home (line start)
|
|
668
|
+
handleHome();
|
|
669
|
+
return true;
|
|
670
|
+
case "e": // Ctrl+E: End (line end)
|
|
671
|
+
handleEnd();
|
|
672
|
+
return true;
|
|
673
|
+
case "k": // Ctrl+K: Kill to end of line
|
|
674
|
+
handleKillToEnd();
|
|
675
|
+
return true;
|
|
676
|
+
case "u": // Ctrl+U: Kill to start of line
|
|
677
|
+
handleKillToStart();
|
|
678
|
+
return true;
|
|
679
|
+
case "w": // Ctrl+W: Delete word backward
|
|
680
|
+
handleDeleteWordBackward();
|
|
681
|
+
return true;
|
|
682
|
+
case "z": // Ctrl+Z: Undo, Ctrl+Shift+Z: Redo
|
|
683
|
+
if (key.shift) {
|
|
684
|
+
handleRedo();
|
|
685
|
+
} else {
|
|
686
|
+
handleUndo();
|
|
687
|
+
}
|
|
688
|
+
return true;
|
|
689
|
+
case "y": // Ctrl+Y: Redo (alternative)
|
|
690
|
+
handleRedo();
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return false;
|
|
696
|
+
},
|
|
697
|
+
[
|
|
698
|
+
handleBackspace,
|
|
699
|
+
handleDelete,
|
|
700
|
+
handleLeftArrow,
|
|
701
|
+
handleRightArrow,
|
|
702
|
+
handleUpArrow,
|
|
703
|
+
handleDownArrow,
|
|
704
|
+
handleReturn,
|
|
705
|
+
handleTab,
|
|
706
|
+
handleHome,
|
|
707
|
+
handleEnd,
|
|
708
|
+
handleKillToEnd,
|
|
709
|
+
handleKillToStart,
|
|
710
|
+
handleDeleteWordBackward,
|
|
711
|
+
handleUndo,
|
|
712
|
+
handleRedo,
|
|
713
|
+
]
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
// Handle keyboard input with immediate display for single chars, buffering for paste
|
|
717
|
+
useInput(
|
|
718
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Keyboard input handler with many key combinations
|
|
719
|
+
(input, key) => {
|
|
720
|
+
if (disabled) return;
|
|
721
|
+
|
|
722
|
+
// Try to process as a special key
|
|
723
|
+
if (processKeyEvent(input, key)) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Handle regular character input
|
|
728
|
+
// Strategy: Single chars ALWAYS display immediately, multi-char inputs (paste) use buffering
|
|
729
|
+
if (input && !key.ctrl && !key.meta) {
|
|
730
|
+
const normalizedInput = normalizeInputValue(input, multiline);
|
|
731
|
+
if (normalizedInput.length === 0) return;
|
|
732
|
+
|
|
733
|
+
// Single character: ALWAYS process immediately for responsive typing
|
|
734
|
+
// Multi-char (paste): use buffering to batch operations
|
|
735
|
+
const isSingleChar = normalizedInput.length === 1;
|
|
736
|
+
|
|
737
|
+
if (isSingleChar) {
|
|
738
|
+
// Clear any pending buffer timer - single chars take priority
|
|
739
|
+
if (inputTimerRef.current) {
|
|
740
|
+
clearTimeout(inputTimerRef.current);
|
|
741
|
+
inputTimerRef.current = null;
|
|
742
|
+
}
|
|
743
|
+
// Flush any buffered content first
|
|
744
|
+
if (inputBufferRef.current.length > 0) {
|
|
745
|
+
const buffered = inputBufferRef.current;
|
|
746
|
+
inputBufferRef.current = "";
|
|
747
|
+
handleInput(buffered);
|
|
748
|
+
}
|
|
749
|
+
// Immediate processing - no buffering delay
|
|
750
|
+
const currentValue = valueRef.current;
|
|
751
|
+
const currentCursorPosition = cursorPositionRef.current;
|
|
752
|
+
|
|
753
|
+
// Check max length before inserting
|
|
754
|
+
if (maxLength !== undefined && currentValue.length >= maxLength) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Insert character at cursor position
|
|
759
|
+
let newValue = insertAt(currentValue, currentCursorPosition, normalizedInput);
|
|
760
|
+
|
|
761
|
+
// Handle max length after insertion
|
|
762
|
+
if (maxLength !== undefined && newValue.length > maxLength) {
|
|
763
|
+
newValue = newValue.slice(0, maxLength);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const newPosition = Math.min(currentCursorPosition + 1, newValue.length);
|
|
767
|
+
// Update value first, then cursor position in low-priority transition
|
|
768
|
+
// This batches both updates in React 18+ automatic batching
|
|
769
|
+
onChange(newValue);
|
|
770
|
+
startTransition(() => {
|
|
771
|
+
setCursorPosition(newPosition);
|
|
772
|
+
});
|
|
773
|
+
} else if (normalizedInput.length > 1) {
|
|
774
|
+
// Multi-character input (paste) only - use buffering to batch paste operations
|
|
775
|
+
inputBufferRef.current += normalizedInput;
|
|
776
|
+
|
|
777
|
+
if (inputTimerRef.current) {
|
|
778
|
+
clearTimeout(inputTimerRef.current);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
inputTimerRef.current = setTimeout(() => {
|
|
782
|
+
const buffered = inputBufferRef.current;
|
|
783
|
+
inputBufferRef.current = "";
|
|
784
|
+
|
|
785
|
+
if (buffered.length === 0) return;
|
|
786
|
+
|
|
787
|
+
// Use refs to get latest values, avoiding closure trap
|
|
788
|
+
const currentValue = valueRef.current;
|
|
789
|
+
const currentCursorPosition = cursorPositionRef.current;
|
|
790
|
+
|
|
791
|
+
// Check max length before inserting
|
|
792
|
+
if (maxLength !== undefined && currentValue.length >= maxLength) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Insert buffered content at cursor position
|
|
797
|
+
let newValue = insertAt(currentValue, currentCursorPosition, buffered);
|
|
798
|
+
|
|
799
|
+
// Handle max length after insertion
|
|
800
|
+
if (maxLength !== undefined && newValue.length > maxLength) {
|
|
801
|
+
newValue = newValue.slice(0, maxLength);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const newPosition = Math.min(currentCursorPosition + buffered.length, newValue.length);
|
|
805
|
+
// Use startTransition for cursor update to reduce flickering during paste
|
|
806
|
+
startTransition(() => {
|
|
807
|
+
setCursorPosition(newPosition);
|
|
808
|
+
});
|
|
809
|
+
onChange(newValue);
|
|
810
|
+
}, RAPID_INPUT_THRESHOLD);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
{ isActive: focused && !disabled }
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
// ==========================================================================
|
|
818
|
+
// Rendering
|
|
819
|
+
// ==========================================================================
|
|
820
|
+
|
|
821
|
+
const isEmpty = value.length === 0;
|
|
822
|
+
const showPlaceholder = isEmpty && placeholder;
|
|
823
|
+
const displayValue = mask ? value.replace(/[\s\S]/g, mask) : value;
|
|
824
|
+
|
|
825
|
+
// Split value into lines for multiline display
|
|
826
|
+
const lines = multiline ? displayValue.split("\n") : [displayValue];
|
|
827
|
+
|
|
828
|
+
// Pre-calculate line start positions for stable keys and rendering
|
|
829
|
+
const lineData = useMemo(() => {
|
|
830
|
+
let pos = 0;
|
|
831
|
+
return lines.map((line, index) => {
|
|
832
|
+
const startPos = pos;
|
|
833
|
+
pos += line.length + 1; // +1 for newline
|
|
834
|
+
return { line, index, startPos, key: getLineKey(startPos) };
|
|
835
|
+
});
|
|
836
|
+
}, [lines]);
|
|
837
|
+
|
|
838
|
+
// Input highlighting (disabled when masking)
|
|
839
|
+
const shouldHighlight = enableHighlight && !mask;
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Render a line with cursor indicator (and optional highlighting)
|
|
843
|
+
*/
|
|
844
|
+
const renderLineWithCursor = (line: string, lineStartPosition: number) => {
|
|
845
|
+
const lineEndPosition = lineStartPosition + line.length;
|
|
846
|
+
const cursorInLine = cursorPosition >= lineStartPosition && cursorPosition <= lineEndPosition;
|
|
847
|
+
|
|
848
|
+
// Use HighlightedText when highlighting is enabled
|
|
849
|
+
if (shouldHighlight) {
|
|
850
|
+
return (
|
|
851
|
+
<HighlightedText
|
|
852
|
+
text={line || " "}
|
|
853
|
+
cursorPosition={cursorPosition}
|
|
854
|
+
showCursor={cursorInLine && focused}
|
|
855
|
+
lineStartPosition={lineStartPosition}
|
|
856
|
+
/>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Default rendering without highlighting
|
|
861
|
+
if (!cursorInLine || !focused) {
|
|
862
|
+
return <Text>{line || " "}</Text>;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const cursorCol = cursorPosition - lineStartPosition;
|
|
866
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
867
|
+
const cursorChar = line[cursorCol] || " ";
|
|
868
|
+
const afterCursor = line.slice(cursorCol + 1);
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<Text>
|
|
872
|
+
{beforeCursor || null}
|
|
873
|
+
<Text inverse>{cursorChar}</Text>
|
|
874
|
+
{afterCursor || null}
|
|
875
|
+
</Text>
|
|
876
|
+
);
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// Border color based on focus state
|
|
880
|
+
const borderColor = focused ? theme.semantic.border.focus : theme.semantic.border.default;
|
|
881
|
+
|
|
882
|
+
// Calculate visual line count considering terminal width wrapping
|
|
883
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
884
|
+
const contentWidth = Math.max(1, terminalWidth - 4); // Account for border (2) + paddingX (1 each side)
|
|
885
|
+
|
|
886
|
+
// Sum visual lines for each logical line (accounts for wrapping)
|
|
887
|
+
const visualLineCount = lineData.reduce((total, { line }) => {
|
|
888
|
+
if (!line) return total + 1;
|
|
889
|
+
// Unicode-aware length for proper CJK/emoji handling
|
|
890
|
+
const lineLength = [...line].length;
|
|
891
|
+
const wrappedLines = Math.ceil(lineLength / contentWidth) || 1;
|
|
892
|
+
return total + wrappedLines;
|
|
893
|
+
}, 0);
|
|
894
|
+
|
|
895
|
+
// Calculate padding lines needed to meet minHeight
|
|
896
|
+
const paddingLinesNeeded = Math.max(0, effectiveMinHeight - visualLineCount);
|
|
897
|
+
|
|
898
|
+
// Render placeholder
|
|
899
|
+
if (showPlaceholder) {
|
|
900
|
+
// Calculate empty lines needed for placeholder (account for the placeholder line itself)
|
|
901
|
+
const emptyLinesForPlaceholder = Math.max(0, effectiveMinHeight - 1);
|
|
902
|
+
return (
|
|
903
|
+
<Box
|
|
904
|
+
flexDirection="column"
|
|
905
|
+
borderStyle="round"
|
|
906
|
+
borderColor={borderColor}
|
|
907
|
+
paddingX={1}
|
|
908
|
+
minHeight={effectiveMinHeight + 2} // +2 for top/bottom border
|
|
909
|
+
>
|
|
910
|
+
<Box flexDirection="row">
|
|
911
|
+
<Text color={theme.semantic.text.primary}>{"❯ "}</Text>
|
|
912
|
+
<Text color={theme.semantic.text.muted}>
|
|
913
|
+
{focused ? (
|
|
914
|
+
<>
|
|
915
|
+
<Text inverse>{placeholder[0] || " "}</Text>
|
|
916
|
+
{placeholder.slice(1)}
|
|
917
|
+
</>
|
|
918
|
+
) : (
|
|
919
|
+
placeholder
|
|
920
|
+
)}
|
|
921
|
+
</Text>
|
|
922
|
+
</Box>
|
|
923
|
+
{/* Add empty lines to maintain minHeight - these are static decorative elements */}
|
|
924
|
+
{Array.from({ length: emptyLinesForPlaceholder }).map((_, i) => (
|
|
925
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: Static placeholder lines don't reorder
|
|
926
|
+
<Text key={`empty-${i}`}> </Text>
|
|
927
|
+
))}
|
|
928
|
+
</Box>
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Render single-line
|
|
933
|
+
if (!multiline) {
|
|
934
|
+
const content = renderLineWithCursor(displayValue, 0);
|
|
935
|
+
if (!showBorder) {
|
|
936
|
+
return (
|
|
937
|
+
<Box flexDirection="row">
|
|
938
|
+
<Text color={theme.semantic.text.primary}>{"❯ "}</Text>
|
|
939
|
+
{content}
|
|
940
|
+
</Box>
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
return (
|
|
944
|
+
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row">
|
|
945
|
+
<Text color={theme.semantic.text.primary}>{"❯ "}</Text>
|
|
946
|
+
<Box flexGrow={1}>{content}</Box>
|
|
947
|
+
</Box>
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Render multiline with stable keys based on line position
|
|
952
|
+
return (
|
|
953
|
+
<Box
|
|
954
|
+
flexDirection="column"
|
|
955
|
+
borderStyle="round"
|
|
956
|
+
borderColor={borderColor}
|
|
957
|
+
paddingX={1}
|
|
958
|
+
minHeight={effectiveMinHeight + 2} // +2 for top/bottom border
|
|
959
|
+
>
|
|
960
|
+
{lineData.map(({ line, startPos, key }, index) => (
|
|
961
|
+
<Box key={key} flexDirection="row">
|
|
962
|
+
{/* Show prompt prefix on first line only */}
|
|
963
|
+
{index === 0 ? (
|
|
964
|
+
<Text color={theme.semantic.text.primary}>{"❯ "}</Text>
|
|
965
|
+
) : (
|
|
966
|
+
<Text>{" "}</Text>
|
|
967
|
+
)}
|
|
968
|
+
<Box flexGrow={1}>{renderLineWithCursor(line, startPos)}</Box>
|
|
969
|
+
</Box>
|
|
970
|
+
))}
|
|
971
|
+
{/* Add empty lines to maintain minHeight - these are static decorative elements */}
|
|
972
|
+
{Array.from({ length: paddingLinesNeeded }).map((_, i) => (
|
|
973
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: Static padding lines don't reorder
|
|
974
|
+
<Text key={`padding-${i}`}> </Text>
|
|
975
|
+
))}
|
|
976
|
+
</Box>
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Memoized TextInput to prevent unnecessary re-renders.
|
|
982
|
+
* Custom comparison checks key props that affect visual output.
|
|
983
|
+
*/
|
|
984
|
+
export const TextInput = memo(TextInputComponent, (prevProps, nextProps) => {
|
|
985
|
+
// Return true if props are equal (skip render)
|
|
986
|
+
return (
|
|
987
|
+
prevProps.value === nextProps.value &&
|
|
988
|
+
prevProps.focused === nextProps.focused &&
|
|
989
|
+
prevProps.disabled === nextProps.disabled &&
|
|
990
|
+
prevProps.mask === nextProps.mask &&
|
|
991
|
+
prevProps.placeholder === nextProps.placeholder &&
|
|
992
|
+
prevProps.suppressEnter === nextProps.suppressEnter &&
|
|
993
|
+
prevProps.cursorToEnd === nextProps.cursorToEnd &&
|
|
994
|
+
prevProps.multiline === nextProps.multiline &&
|
|
995
|
+
prevProps.maxLength === nextProps.maxLength &&
|
|
996
|
+
prevProps.minHeight === nextProps.minHeight &&
|
|
997
|
+
prevProps.showBorder === nextProps.showBorder &&
|
|
998
|
+
prevProps.enableHighlight === nextProps.enableHighlight
|
|
999
|
+
);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
export default TextInput;
|