@alumbwe/anvil 1.0.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/README.md +84 -0
- package/bin/anvil +2 -0
- package/bin/tree-sitter.wasm +0 -0
- package/package.json +67 -0
- package/src/__tests__/README.md +256 -0
- package/src/__tests__/bash-mode.test.ts +442 -0
- package/src/__tests__/cli-args.test.ts +181 -0
- package/src/__tests__/cli-harness.test.ts +52 -0
- package/src/__tests__/e2e/first-time-login.test.ts +173 -0
- package/src/__tests__/e2e/logout-relogin-flow.test.ts +110 -0
- package/src/__tests__/e2e/returning-user-auth.test.ts +113 -0
- package/src/__tests__/e2e-cli.test.ts +183 -0
- package/src/__tests__/helpers/launcher-disconnect-fixture.cjs +176 -0
- package/src/__tests__/helpers/mock-api-client.ts +64 -0
- package/src/__tests__/helpers/renderer-cleanup-fixture.tsx +89 -0
- package/src/__tests__/helpers/terminal-watchdog-fixture.ts +75 -0
- package/src/__tests__/home-directory-detection.test.ts +213 -0
- package/src/__tests__/integration/api-integration.test.ts +288 -0
- package/src/__tests__/integration/credentials-storage.test.ts +486 -0
- package/src/__tests__/integration/local-agents.test.ts +1187 -0
- package/src/__tests__/integration/login-flow-instrumentation.test.ts +134 -0
- package/src/__tests__/integration/login-polling-working.test.ts +285 -0
- package/src/__tests__/integration/queue-editing.test.tsx +261 -0
- package/src/__tests__/integration/usage-refresh-on-completion.test.ts +168 -0
- package/src/__tests__/integration-tmux.test.ts +188 -0
- package/src/__tests__/launcher-avx2-fallback.test.ts +630 -0
- package/src/__tests__/launcher-disconnect.test.ts +104 -0
- package/src/__tests__/mocks/hover-toggle-controller.ts +71 -0
- package/src/__tests__/open-tui-native-bundle.test.ts +378 -0
- package/src/__tests__/path-completion.test.ts +243 -0
- package/src/__tests__/project-files-chat-id.test.ts +34 -0
- package/src/__tests__/release/proxy-http-get.test.ts +667 -0
- package/src/__tests__/release/wrapper-safety.test.ts +412 -0
- package/src/__tests__/renderer-cleanup.test.ts +174 -0
- package/src/__tests__/rerender-perf.integration.test.ts +283 -0
- package/src/__tests__/terminal-reset-sequences.test.ts +36 -0
- package/src/__tests__/terminal-watchdog.test.ts +388 -0
- package/src/__tests__/test-utils.ts +271 -0
- package/src/__tests__/tmux-poc.ts +150 -0
- package/src/__tests__/unit/agent-mode-toggle.test.ts +137 -0
- package/src/__tests__/unit/copy-button.test.ts +157 -0
- package/src/__tests__/unit/create-run-config.test.ts +66 -0
- package/src/__tests__/unit/publish-confirmation.test.ts +70 -0
- package/src/__tests__/unit/segmented-control.test.ts +152 -0
- package/src/__tests__/utils/env.test.ts +169 -0
- package/src/__tests__/utils/project-picker.test.ts +39 -0
- package/src/agents/bundled-agents.generated.d.ts +14 -0
- package/src/agents/bundled-agents.generated.ts +3692 -0
- package/src/app.tsx +399 -0
- package/src/chat.tsx +1698 -0
- package/src/cli-args.ts +134 -0
- package/src/commands/__tests__/bash-command.test.ts +445 -0
- package/src/commands/__tests__/command-args.test.ts +349 -0
- package/src/commands/__tests__/copy-conversation-clipboard.test.ts +108 -0
- package/src/commands/__tests__/copy-conversation.test.ts +226 -0
- package/src/commands/__tests__/diagnostics-command.test.ts +35 -0
- package/src/commands/__tests__/freebuff-command-aliases.test.ts +56 -0
- package/src/commands/__tests__/image.test.ts +99 -0
- package/src/commands/__tests__/init.test.ts +456 -0
- package/src/commands/__tests__/new-command-rotates-chat.test.ts +91 -0
- package/src/commands/__tests__/process-diagnostics.test.ts +54 -0
- package/src/commands/__tests__/prompt-builders.test.ts +28 -0
- package/src/commands/__tests__/router-input.test.ts +298 -0
- package/src/commands/ads.ts +44 -0
- package/src/commands/command-registry.ts +726 -0
- package/src/commands/copy-conversation.ts +392 -0
- package/src/commands/help.ts +13 -0
- package/src/commands/image.ts +20 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/process-diagnostics.ts +120 -0
- package/src/commands/prompt-builders.ts +100 -0
- package/src/commands/publish.ts +203 -0
- package/src/commands/router-utils.ts +80 -0
- package/src/commands/router.ts +487 -0
- package/src/commands/usage.ts +26 -0
- package/src/components/__tests__/ad-banner.test.tsx +83 -0
- package/src/components/__tests__/chat-input-bar.test.tsx +130 -0
- package/src/components/__tests__/freebuff-landing-heading-row.test.tsx +91 -0
- package/src/components/__tests__/freebuff-model-selector.test.tsx +519 -0
- package/src/components/__tests__/freebuff-offer-invariants.test.ts +54 -0
- package/src/components/__tests__/freebuff-takeover-prompt.test.tsx +212 -0
- package/src/components/__tests__/grid-layout.integration.test.tsx +304 -0
- package/src/components/__tests__/grid-layout.test.tsx +1051 -0
- package/src/components/__tests__/message-block.completion.test.tsx +104 -0
- package/src/components/__tests__/message-block.streaming.test.tsx +78 -0
- package/src/components/__tests__/message-with-agents.test.tsx +579 -0
- package/src/components/__tests__/multiline-input.test.tsx +1132 -0
- package/src/components/__tests__/queue-panel.test.tsx +398 -0
- package/src/components/__tests__/selectable-list.test.ts +232 -0
- package/src/components/__tests__/status-bar.test.tsx +44 -0
- package/src/components/__tests__/status-indicator.test.tsx +204 -0
- package/src/components/__tests__/terminal-thinking-orb.test.tsx +111 -0
- package/src/components/__tests__/user-error-banner.test.tsx +102 -0
- package/src/components/ad-banner.tsx +323 -0
- package/src/components/agent-checklist.tsx +350 -0
- package/src/components/agent-mode-toggle.tsx +241 -0
- package/src/components/anvil-mark.tsx +52 -0
- package/src/components/ask-user/__tests__/multiple-choice-form.test.ts +432 -0
- package/src/components/ask-user/__tests__/validation.test.ts +213 -0
- package/src/components/ask-user/components/accordion-question.tsx +153 -0
- package/src/components/ask-user/components/custom-answer-input.tsx +66 -0
- package/src/components/ask-user/components/options-list.tsx +133 -0
- package/src/components/ask-user/components/question-header.tsx +80 -0
- package/src/components/ask-user/components/question-option.tsx +89 -0
- package/src/components/ask-user/constants.ts +38 -0
- package/src/components/ask-user/index.tsx +631 -0
- package/src/components/ask-user/utils/validation.ts +85 -0
- package/src/components/attachment-card.tsx +65 -0
- package/src/components/blocks/agent-block-grid.tsx +58 -0
- package/src/components/blocks/agent-branch-item.tsx +311 -0
- package/src/components/blocks/agent-branch-wrapper.tsx +493 -0
- package/src/components/blocks/agent-list-branch.tsx +77 -0
- package/src/components/blocks/ask-user-branch.tsx +81 -0
- package/src/components/blocks/block-helpers.ts +11 -0
- package/src/components/blocks/blocks-renderer.tsx +289 -0
- package/src/components/blocks/content-with-markdown.tsx +33 -0
- package/src/components/blocks/image-block.tsx +128 -0
- package/src/components/blocks/implementor-row.tsx +429 -0
- package/src/components/blocks/single-block.tsx +201 -0
- package/src/components/blocks/thinking-block.tsx +67 -0
- package/src/components/blocks/tool-block-group.tsx +57 -0
- package/src/components/blocks/tool-branch.tsx +206 -0
- package/src/components/blocks/user-content-copy.tsx +157 -0
- package/src/components/bottom-banner.tsx +136 -0
- package/src/components/build-mode-buttons.tsx +110 -0
- package/src/components/button.tsx +66 -0
- package/src/components/chat-header.tsx +77 -0
- package/src/components/chat-history-screen.tsx +413 -0
- package/src/components/chat-input-bar.tsx +523 -0
- package/src/components/clickable-title-box.tsx +66 -0
- package/src/components/clickable.tsx +119 -0
- package/src/components/collapse-button.tsx +34 -0
- package/src/components/copy-button.tsx +203 -0
- package/src/components/elapsed-timer.tsx +55 -0
- package/src/components/error-boundary.tsx +55 -0
- package/src/components/feedback-container.tsx +181 -0
- package/src/components/feedback-icon-button.tsx +89 -0
- package/src/components/feedback-input-mode.tsx +344 -0
- package/src/components/file-attachment-card.tsx +98 -0
- package/src/components/freebuff-active-session-summary.tsx +63 -0
- package/src/components/freebuff-landing-screen.tsx +865 -0
- package/src/components/freebuff-model-selector.tsx +1087 -0
- package/src/components/freebuff-referral-banner.tsx +604 -0
- package/src/components/freebuff-superseded-screen.tsx +62 -0
- package/src/components/grid-layout.tsx +80 -0
- package/src/components/help-banner.tsx +127 -0
- package/src/components/highlighted-text.tsx +52 -0
- package/src/components/image-card.tsx +167 -0
- package/src/components/image-thumbnail.tsx +114 -0
- package/src/components/input-cursor.tsx +77 -0
- package/src/components/input-mode-banner.tsx +57 -0
- package/src/components/load-previous-button.tsx +48 -0
- package/src/components/login-modal.tsx +475 -0
- package/src/components/message-block.tsx +346 -0
- package/src/components/message-footer.tsx +258 -0
- package/src/components/message-with-agents.tsx +506 -0
- package/src/components/mode-divider.tsx +43 -0
- package/src/components/multiline-input.tsx +1241 -0
- package/src/components/out-of-credits-banner.tsx +160 -0
- package/src/components/pending-attachments-banner.tsx +116 -0
- package/src/components/pending-bash-message.tsx +59 -0
- package/src/components/progress-bar.tsx +81 -0
- package/src/components/project-picker-screen.tsx +485 -0
- package/src/components/publish-confirmation.tsx +493 -0
- package/src/components/publish-container.tsx +687 -0
- package/src/components/queue-panel.tsx +310 -0
- package/src/components/raised-pill.tsx +95 -0
- package/src/components/renderers/plan-box.tsx +59 -0
- package/src/components/review-screen.tsx +112 -0
- package/src/components/scroll-to-bottom-button.tsx +34 -0
- package/src/components/segmented-control.tsx +187 -0
- package/src/components/selectable-list.tsx +248 -0
- package/src/components/selected-chips.tsx +80 -0
- package/src/components/separator.tsx +40 -0
- package/src/components/session-ended-banner.tsx +226 -0
- package/src/components/shimmer-text.tsx +248 -0
- package/src/components/status-bar.tsx +313 -0
- package/src/components/subscription-limit-banner.tsx +206 -0
- package/src/components/suggested-prompts.tsx +184 -0
- package/src/components/suggestion-menu.tsx +215 -0
- package/src/components/terminal-command-display.tsx +151 -0
- package/src/components/terminal-link.tsx +97 -0
- package/src/components/terminal-thinking-orb.tsx +95 -0
- package/src/components/text-attachment-card.tsx +77 -0
- package/src/components/thinking.tsx +109 -0
- package/src/components/tools/__tests__/apply-patch.test.tsx +84 -0
- package/src/components/tools/__tests__/code-search.test.tsx +45 -0
- package/src/components/tools/__tests__/gravity-index.test.ts +76 -0
- package/src/components/tools/__tests__/render-ui.test.tsx +92 -0
- package/src/components/tools/__tests__/run-terminal-command.test.ts +233 -0
- package/src/components/tools/apply-patch.tsx +95 -0
- package/src/components/tools/code-search.tsx +45 -0
- package/src/components/tools/composio.tsx +152 -0
- package/src/components/tools/diff-viewer.tsx +76 -0
- package/src/components/tools/glob.tsx +61 -0
- package/src/components/tools/gravity-index.tsx +77 -0
- package/src/components/tools/list-directory.tsx +61 -0
- package/src/components/tools/read-docs.tsx +37 -0
- package/src/components/tools/read-files.tsx +91 -0
- package/src/components/tools/read-subtree.tsx +44 -0
- package/src/components/tools/read-url.tsx +33 -0
- package/src/components/tools/registry.ts +126 -0
- package/src/components/tools/render-ui.tsx +122 -0
- package/src/components/tools/run-terminal-command.tsx +79 -0
- package/src/components/tools/skill.tsx +29 -0
- package/src/components/tools/str-replace.tsx +77 -0
- package/src/components/tools/suggest-followups.tsx +369 -0
- package/src/components/tools/task-completed.tsx +16 -0
- package/src/components/tools/tool-call-item.tsx +273 -0
- package/src/components/tools/types.ts +55 -0
- package/src/components/tools/web-search.tsx +33 -0
- package/src/components/tools/write-file.tsx +16 -0
- package/src/components/tools/write-todos.tsx +95 -0
- package/src/components/top-banner.tsx +178 -0
- package/src/components/usage-banner.tsx +244 -0
- package/src/components/user-error-banner.tsx +56 -0
- package/src/components/validation-error-popover.tsx +192 -0
- package/src/contexts/__tests__/chat-runtime-context.test.tsx +149 -0
- package/src/contexts/chat-runtime-context.tsx +240 -0
- package/src/data/slash-commands.test.ts +24 -0
- package/src/data/slash-commands.ts +249 -0
- package/src/entry.ts +12 -0
- package/src/hooks/__tests__/holds-live-freebuff-slot.test.ts +49 -0
- package/src/hooks/__tests__/session-fetch-signal.test.ts +63 -0
- package/src/hooks/__tests__/use-activity-query.test.ts +1000 -0
- package/src/hooks/__tests__/use-ask-user-bridge.test.ts +176 -0
- package/src/hooks/__tests__/use-auth-query.test.ts +21 -0
- package/src/hooks/__tests__/use-chat-input-remount.test.tsx +66 -0
- package/src/hooks/__tests__/use-clipboard.test.tsx +73 -0
- package/src/hooks/__tests__/use-connection-status.test.ts +112 -0
- package/src/hooks/__tests__/use-directory-browser.test.ts +203 -0
- package/src/hooks/__tests__/use-gravity-ad.test.ts +73 -0
- package/src/hooks/__tests__/use-grid-layout.test.ts +349 -0
- package/src/hooks/__tests__/use-input-history.test.ts +699 -0
- package/src/hooks/__tests__/use-message-queue-editing.test.tsx +209 -0
- package/src/hooks/__tests__/use-message-queue.test.tsx +71 -0
- package/src/hooks/__tests__/use-path-tab-completion.test.ts +315 -0
- package/src/hooks/__tests__/use-queue-controls.test.ts +68 -0
- package/src/hooks/__tests__/use-queue-ui.test.tsx +83 -0
- package/src/hooks/__tests__/use-searchable-list.test.ts +359 -0
- package/src/hooks/__tests__/use-send-message-timer.test.ts +146 -0
- package/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +361 -0
- package/src/hooks/__tests__/use-suggestion-engine.test.ts +485 -0
- package/src/hooks/__tests__/use-terminal-focus.test.ts +66 -0
- package/src/hooks/__tests__/use-terminal-layout.test.ts +675 -0
- package/src/hooks/__tests__/use-timeout.test.ts +330 -0
- package/src/hooks/__tests__/use-usage-query.test.ts +502 -0
- package/src/hooks/__tests__/use-user-details-query.test.ts +195 -0
- package/src/hooks/helpers/__tests__/send-message.test.ts +1918 -0
- package/src/hooks/helpers/send-message.ts +654 -0
- package/src/hooks/stream-state.ts +83 -0
- package/src/hooks/use-activity-query.ts +668 -0
- package/src/hooks/use-agent-validation.ts +74 -0
- package/src/hooks/use-ask-user-bridge.ts +92 -0
- package/src/hooks/use-auth-query.ts +253 -0
- package/src/hooks/use-auth-state.ts +152 -0
- package/src/hooks/use-chat-input.ts +105 -0
- package/src/hooks/use-chat-keyboard.ts +321 -0
- package/src/hooks/use-chat-messages.ts +254 -0
- package/src/hooks/use-chat-state.ts +165 -0
- package/src/hooks/use-chat-streaming.ts +180 -0
- package/src/hooks/use-chat-ui.ts +131 -0
- package/src/hooks/use-clipboard.ts +151 -0
- package/src/hooks/use-connection-status.ts +142 -0
- package/src/hooks/use-directory-browser.ts +88 -0
- package/src/hooks/use-elapsed-time.ts +116 -0
- package/src/hooks/use-event.ts +34 -0
- package/src/hooks/use-exit-handler.ts +66 -0
- package/src/hooks/use-fetch-login-url.ts +65 -0
- package/src/hooks/use-fingerprint.ts +61 -0
- package/src/hooks/use-freebuff-ctrl-c-exit.ts +23 -0
- package/src/hooks/use-freebuff-session-progress.ts +34 -0
- package/src/hooks/use-freebuff-session.ts +806 -0
- package/src/hooks/use-freebuff-streak-query.ts +61 -0
- package/src/hooks/use-gravity-ad.ts +656 -0
- package/src/hooks/use-grid-layout.ts +73 -0
- package/src/hooks/use-input-history.ts +173 -0
- package/src/hooks/use-login-keyboard-handlers.ts +75 -0
- package/src/hooks/use-login-polling.ts +114 -0
- package/src/hooks/use-logo.tsx +161 -0
- package/src/hooks/use-message-queue.ts +387 -0
- package/src/hooks/use-now.ts +20 -0
- package/src/hooks/use-path-tab-completion.ts +88 -0
- package/src/hooks/use-publish-mutation.ts +60 -0
- package/src/hooks/use-queue-controls.ts +68 -0
- package/src/hooks/use-queue-ui.ts +74 -0
- package/src/hooks/use-scroll-management.ts +184 -0
- package/src/hooks/use-searchable-list.ts +98 -0
- package/src/hooks/use-send-message.ts +773 -0
- package/src/hooks/use-sheen-animation.tsx +88 -0
- package/src/hooks/use-subscription-query.ts +71 -0
- package/src/hooks/use-suggestion-engine.ts +783 -0
- package/src/hooks/use-terminal-breakpoints.ts +77 -0
- package/src/hooks/use-terminal-dimensions.ts +46 -0
- package/src/hooks/use-terminal-focus.ts +29 -0
- package/src/hooks/use-terminal-layout.ts +189 -0
- package/src/hooks/use-theme.tsx +140 -0
- package/src/hooks/use-timeout.ts +85 -0
- package/src/hooks/use-update-preference.ts +66 -0
- package/src/hooks/use-usage-monitor.ts +55 -0
- package/src/hooks/use-usage-query.ts +124 -0
- package/src/hooks/use-user-details-query.ts +93 -0
- package/src/hooks/use-why-did-you-update.ts +177 -0
- package/src/index.tsx +441 -0
- package/src/init/__tests__/init-direnv.test.ts +526 -0
- package/src/init/init-app.ts +34 -0
- package/src/init/init-direnv.ts +133 -0
- package/src/login/constants.ts +60 -0
- package/src/login/login-flow.ts +204 -0
- package/src/login/plain-login.ts +108 -0
- package/src/login/utils.ts +183 -0
- package/src/native/ripgrep.ts +80 -0
- package/src/polyfills/bun-strip-ansi.ts +12 -0
- package/src/pre-init/tree-sitter-wasm.ts +96 -0
- package/src/project-files.ts +102 -0
- package/src/smoke/terminal-command-broker.ts +439 -0
- package/src/state/__tests__/chat-store-focus.test.ts +22 -0
- package/src/state/__tests__/feedback-store.test.ts +257 -0
- package/src/state/chat-history-store.ts +30 -0
- package/src/state/chat-store.ts +544 -0
- package/src/state/feedback-store.ts +160 -0
- package/src/state/freebuff-model-store.ts +42 -0
- package/src/state/freebuff-session-store.ts +53 -0
- package/src/state/login-store.ts +150 -0
- package/src/state/message-block-store.ts +149 -0
- package/src/state/publish-store.ts +146 -0
- package/src/state/queue-panel-store.ts +26 -0
- package/src/state/review-store.ts +24 -0
- package/src/testing/env.ts +46 -0
- package/src/types/chat-state.ts +15 -0
- package/src/types/chat.ts +241 -0
- package/src/types/contracts/send-message.ts +12 -0
- package/src/types/env.ts +98 -0
- package/src/types/freebuff-session.ts +17 -0
- package/src/types/function-params.ts +28 -0
- package/src/types/react19-compat.d.ts +19 -0
- package/src/types/store.ts +111 -0
- package/src/types/theme-system.ts +144 -0
- package/src/types/utils.ts +3 -0
- package/src/utils/__tests__/active-run.test.ts +140 -0
- package/src/utils/__tests__/activity-tracker.test.ts +440 -0
- package/src/utils/__tests__/agent-display.test.ts +139 -0
- package/src/utils/__tests__/analytics-client.test.ts +262 -0
- package/src/utils/__tests__/anonymous-id.test.ts +74 -0
- package/src/utils/__tests__/anvil-api.test.ts +578 -0
- package/src/utils/__tests__/arrays.test.ts +264 -0
- package/src/utils/__tests__/bash-context-processor.test.ts +38 -0
- package/src/utils/__tests__/block-processor.test.ts +810 -0
- package/src/utils/__tests__/chat-history.test.ts +203 -0
- package/src/utils/__tests__/chat-input-key-intercept.test.ts +40 -0
- package/src/utils/__tests__/chat-meta.test.ts +130 -0
- package/src/utils/__tests__/clipboard-linux.test.ts +359 -0
- package/src/utils/__tests__/clipboard.test.ts +910 -0
- package/src/utils/__tests__/code-search-summary.test.ts +84 -0
- package/src/utils/__tests__/collapse-helpers.test.ts +1109 -0
- package/src/utils/__tests__/error-handling.test.ts +658 -0
- package/src/utils/__tests__/exit-cleanly.test.ts +110 -0
- package/src/utils/__tests__/feedback-helpers.test.ts +444 -0
- package/src/utils/__tests__/feedback-submission.test.ts +26 -0
- package/src/utils/__tests__/fetch-usage.test.ts +355 -0
- package/src/utils/__tests__/fingerprint.test.ts +144 -0
- package/src/utils/__tests__/fixtures/terminal-command-broker-child.ts +7 -0
- package/src/utils/__tests__/fixtures/terminal-command-broker-entry.ts +3 -0
- package/src/utils/__tests__/fixtures/terminal-command-broker-owner.ts +33 -0
- package/src/utils/__tests__/format-elapsed-time.test.ts +103 -0
- package/src/utils/__tests__/format-timeout.test.ts +87 -0
- package/src/utils/__tests__/freebuff-instance-owner.test.ts +69 -0
- package/src/utils/__tests__/freebuff-model-navigation.test.ts +102 -0
- package/src/utils/__tests__/freebuff-premium-reset.test.ts +79 -0
- package/src/utils/__tests__/freebuff-referral-cache.test.ts +116 -0
- package/src/utils/__tests__/freebuff-session-api.test.ts +215 -0
- package/src/utils/__tests__/freebuff-session-display.test.ts +23 -0
- package/src/utils/__tests__/freebuff-streak-line.test.ts +217 -0
- package/src/utils/__tests__/image-dimensions.test.ts +249 -0
- package/src/utils/__tests__/image-processor.test.ts +209 -0
- package/src/utils/__tests__/implementor-helpers.test.ts +1458 -0
- package/src/utils/__tests__/keyboard-actions.test.ts +660 -0
- package/src/utils/__tests__/layout-helpers.test.ts +36 -0
- package/src/utils/__tests__/lazy-response-ads.test.ts +167 -0
- package/src/utils/__tests__/log-shipper.test.ts +53 -0
- package/src/utils/__tests__/markdown-renderer.test.tsx +421 -0
- package/src/utils/__tests__/message-block-helpers.test.ts +1263 -0
- package/src/utils/__tests__/message-updater.test.ts +645 -0
- package/src/utils/__tests__/osc-timeout-scenarios.test.ts +172 -0
- package/src/utils/__tests__/pending-attachments.test.ts +277 -0
- package/src/utils/__tests__/polling-backoff.test.ts +51 -0
- package/src/utils/__tests__/queue-panel-actions.test.ts +148 -0
- package/src/utils/__tests__/run-state-storage.test.ts +919 -0
- package/src/utils/__tests__/safe-json.test.ts +77 -0
- package/src/utils/__tests__/sdk-event-handlers.test.ts +685 -0
- package/src/utils/__tests__/send-message-helpers.test.ts +1898 -0
- package/src/utils/__tests__/send-message-timer.test.ts +64 -0
- package/src/utils/__tests__/settings.test.ts +99 -0
- package/src/utils/__tests__/strings.test.ts +192 -0
- package/src/utils/__tests__/terminal-color-detection.test.ts +425 -0
- package/src/utils/__tests__/terminal-command-broker.test.ts +528 -0
- package/src/utils/__tests__/terminal-enter-detection.test.ts +77 -0
- package/src/utils/__tests__/terminal-io.test.ts +137 -0
- package/src/utils/__tests__/terminal-protocol-controller.test.ts +119 -0
- package/src/utils/__tests__/text-layout.test.ts +82 -0
- package/src/utils/__tests__/theme-platform-detection.test.ts +125 -0
- package/src/utils/__tests__/think-tag-parser.test.ts +159 -0
- package/src/utils/__tests__/trace-writer.test.ts +223 -0
- package/src/utils/__tests__/trim-chat-logs.test.ts +78 -0
- package/src/utils/__tests__/usage-banner-state.test.ts +298 -0
- package/src/utils/__tests__/validation-error-formatting.test.ts +126 -0
- package/src/utils/__tests__/windows-terminal-health.test.ts +101 -0
- package/src/utils/__tests__/write-file-atomic.test.ts +127 -0
- package/src/utils/active-run.ts +131 -0
- package/src/utils/activity-tracker.ts +66 -0
- package/src/utils/agent-display.ts +87 -0
- package/src/utils/agent-helpers.ts +27 -0
- package/src/utils/agent-id-utils.ts +14 -0
- package/src/utils/ai-message-id.ts +12 -0
- package/src/utils/analytics.ts +361 -0
- package/src/utils/anonymous-id.ts +87 -0
- package/src/utils/anvil-api.ts +612 -0
- package/src/utils/anvil-client.ts +217 -0
- package/src/utils/arrays.ts +117 -0
- package/src/utils/auth.ts +258 -0
- package/src/utils/bash-context-processor.ts +47 -0
- package/src/utils/bash-messages.ts +108 -0
- package/src/utils/block-margins.ts +35 -0
- package/src/utils/block-operations.ts +523 -0
- package/src/utils/block-processor.ts +213 -0
- package/src/utils/chat-history.ts +253 -0
- package/src/utils/chat-input-key-intercept.ts +52 -0
- package/src/utils/chat-meta.ts +90 -0
- package/src/utils/chat-scroll-accel.ts +151 -0
- package/src/utils/clipboard-image.ts +588 -0
- package/src/utils/clipboard.ts +397 -0
- package/src/utils/code-search-summary.ts +70 -0
- package/src/utils/collapse-helpers.ts +256 -0
- package/src/utils/config-dir.ts +24 -0
- package/src/utils/constants.ts +190 -0
- package/src/utils/create-event-handler-state.ts +73 -0
- package/src/utils/create-run-config.ts +121 -0
- package/src/utils/detect-shell.ts +116 -0
- package/src/utils/directory-browser.ts +72 -0
- package/src/utils/engagement.ts +55 -0
- package/src/utils/env.ts +92 -0
- package/src/utils/error-handling.ts +255 -0
- package/src/utils/error-messages.ts +62 -0
- package/src/utils/exit-cleanly.ts +86 -0
- package/src/utils/feedback-helpers.ts +103 -0
- package/src/utils/feedback-submission.ts +22 -0
- package/src/utils/fetch-usage.ts +76 -0
- package/src/utils/fingerprint.ts +245 -0
- package/src/utils/format-elapsed-time.ts +33 -0
- package/src/utils/format-session-units.ts +6 -0
- package/src/utils/format-timeout.ts +28 -0
- package/src/utils/format-validation-errors-for-message.ts +84 -0
- package/src/utils/freebuff-agent-selection.ts +33 -0
- package/src/utils/freebuff-instance-owner.ts +66 -0
- package/src/utils/freebuff-model-navigation.ts +50 -0
- package/src/utils/freebuff-premium-reset.ts +55 -0
- package/src/utils/freebuff-referral-cache.ts +51 -0
- package/src/utils/freebuff-session-api.ts +243 -0
- package/src/utils/freebuff-session-display.ts +21 -0
- package/src/utils/freebuff-streak-line.ts +93 -0
- package/src/utils/git.ts +17 -0
- package/src/utils/helpers.ts +47 -0
- package/src/utils/image-display.ts +67 -0
- package/src/utils/image-handler.ts +325 -0
- package/src/utils/image-processor.ts +133 -0
- package/src/utils/image-thumbnail.ts +78 -0
- package/src/utils/implementor-helpers.ts +787 -0
- package/src/utils/input-modes.ts +174 -0
- package/src/utils/keyboard-actions.ts +392 -0
- package/src/utils/keypad-keys.ts +47 -0
- package/src/utils/layout-helpers.ts +33 -0
- package/src/utils/lazy-response-ads.ts +1 -0
- package/src/utils/local-agent-registry.ts +474 -0
- package/src/utils/log-shipper.ts +126 -0
- package/src/utils/logger.ts +327 -0
- package/src/utils/markdown-renderer.tsx +1090 -0
- package/src/utils/math.ts +3 -0
- package/src/utils/message-block-helpers.ts +684 -0
- package/src/utils/message-history.ts +143 -0
- package/src/utils/message-tree-utils.ts +67 -0
- package/src/utils/message-updater.ts +279 -0
- package/src/utils/open-file.ts +146 -0
- package/src/utils/open-url.ts +63 -0
- package/src/utils/path-completion.ts +111 -0
- package/src/utils/path-helpers.ts +38 -0
- package/src/utils/pending-attachments.ts +360 -0
- package/src/utils/polling-backoff.ts +59 -0
- package/src/utils/project-picker.ts +12 -0
- package/src/utils/queue-panel-actions.ts +70 -0
- package/src/utils/recent-projects.ts +156 -0
- package/src/utils/renderer-cleanup.ts +233 -0
- package/src/utils/response-ad-positions.ts +1 -0
- package/src/utils/run-state-storage.ts +603 -0
- package/src/utils/safe-json.ts +153 -0
- package/src/utils/sdk-event-handlers.ts +571 -0
- package/src/utils/send-message-helpers.ts +210 -0
- package/src/utils/send-message-timer.ts +110 -0
- package/src/utils/settings.ts +226 -0
- package/src/utils/skill-registry.ts +94 -0
- package/src/utils/spawn-agent-matcher.ts +55 -0
- package/src/utils/status-indicator-state.ts +107 -0
- package/src/utils/stream-chunk-processor.ts +57 -0
- package/src/utils/strings.ts +271 -0
- package/src/utils/subscription.ts +31 -0
- package/src/utils/syntax-highlighter.tsx +19 -0
- package/src/utils/terminal-color-detection.ts +474 -0
- package/src/utils/terminal-command-broker.ts +467 -0
- package/src/utils/terminal-enter-detection.ts +63 -0
- package/src/utils/terminal-images.ts +229 -0
- package/src/utils/terminal-io.ts +85 -0
- package/src/utils/terminal-protocol-controller.ts +173 -0
- package/src/utils/terminal-reset-sequences.ts +33 -0
- package/src/utils/terminal-title.ts +84 -0
- package/src/utils/terminal-watchdog.ts +366 -0
- package/src/utils/text-layout.ts +149 -0
- package/src/utils/theme-config.ts +141 -0
- package/src/utils/theme-system.ts +1206 -0
- package/src/utils/think-tag-parser.ts +95 -0
- package/src/utils/time-format.ts +21 -0
- package/src/utils/trace-writer.ts +162 -0
- package/src/utils/ui-constants.ts +69 -0
- package/src/utils/update-checker.ts +110 -0
- package/src/utils/usage-banner-state.ts +146 -0
- package/src/utils/validation-error-formatting.ts +86 -0
- package/src/utils/validation-error-helpers.ts +19 -0
- package/src/utils/windows-terminal-health.ts +124 -0
- package/src/utils/word-wrap-utils.ts +53 -0
- package/src/utils/write-file-atomic.ts +54 -0
- package/src/utils/yield-to-event-loop.ts +9 -0
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePasteBytes,
|
|
3
|
+
stripAnsiSequences,
|
|
4
|
+
TextAttributes,
|
|
5
|
+
} from '@opentui/core'
|
|
6
|
+
import { useAppContext, useKeyboard, useRenderer } from '@opentui/react'
|
|
7
|
+
import {
|
|
8
|
+
forwardRef,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useImperativeHandle,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react'
|
|
15
|
+
|
|
16
|
+
import { InputCursor } from './input-cursor'
|
|
17
|
+
import { useTheme } from '../hooks/use-theme'
|
|
18
|
+
import { useChatStore } from '../state/chat-store'
|
|
19
|
+
import {
|
|
20
|
+
getKeypadPrintableSequence,
|
|
21
|
+
isKeypadEnter,
|
|
22
|
+
} from '../utils/keypad-keys'
|
|
23
|
+
import { clamp } from '../utils/math'
|
|
24
|
+
import {
|
|
25
|
+
isLinefeedActingAsEnter,
|
|
26
|
+
markReturnKeySeenForKey,
|
|
27
|
+
} from '../utils/terminal-enter-detection'
|
|
28
|
+
import { supportsTruecolor } from '../utils/theme-system'
|
|
29
|
+
import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
|
|
30
|
+
|
|
31
|
+
import type { InputValue } from '../types/store'
|
|
32
|
+
import type {
|
|
33
|
+
KeyEvent,
|
|
34
|
+
MouseEvent,
|
|
35
|
+
PasteEvent,
|
|
36
|
+
ScrollBoxRenderable,
|
|
37
|
+
TextBufferView,
|
|
38
|
+
TextRenderable,
|
|
39
|
+
} from '@opentui/core'
|
|
40
|
+
|
|
41
|
+
function getPasteText(event: PasteEvent): string {
|
|
42
|
+
return stripAnsiSequences(decodePasteBytes(event.bytes))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Helper functions for text manipulation
|
|
46
|
+
function findLineStart(text: string, cursor: number): number {
|
|
47
|
+
let pos = Math.max(0, Math.min(cursor, text.length))
|
|
48
|
+
while (pos > 0 && text[pos - 1] !== '\n') {
|
|
49
|
+
pos--
|
|
50
|
+
}
|
|
51
|
+
return pos
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findLineEnd(text: string, cursor: number): number {
|
|
55
|
+
let pos = Math.max(0, Math.min(cursor, text.length))
|
|
56
|
+
while (pos < text.length && text[pos] !== '\n') {
|
|
57
|
+
pos++
|
|
58
|
+
}
|
|
59
|
+
return pos
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findPreviousWordBoundary(text: string, cursor: number): number {
|
|
63
|
+
let pos = Math.max(0, Math.min(cursor, text.length))
|
|
64
|
+
|
|
65
|
+
// Skip whitespace backwards
|
|
66
|
+
while (pos > 0 && /\s/.test(text[pos - 1])) {
|
|
67
|
+
pos--
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Skip word characters backwards
|
|
71
|
+
while (pos > 0 && !/\s/.test(text[pos - 1])) {
|
|
72
|
+
pos--
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return pos
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findNextWordBoundary(text: string, cursor: number): number {
|
|
79
|
+
let pos = Math.max(0, Math.min(cursor, text.length))
|
|
80
|
+
|
|
81
|
+
// Skip non-whitespace forwards
|
|
82
|
+
while (pos < text.length && !/\s/.test(text[pos])) {
|
|
83
|
+
pos++
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Skip whitespace forwards
|
|
87
|
+
while (pos < text.length && /\s/.test(text[pos])) {
|
|
88
|
+
pos++
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return pos
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const CURSOR_CHAR = '▍'
|
|
95
|
+
const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f]/
|
|
96
|
+
const TAB_WIDTH = 4
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a key event represents printable character input (not a special key).
|
|
100
|
+
* Uses a positive heuristic based on key.name length rather than a brittle deny-list.
|
|
101
|
+
*
|
|
102
|
+
* The key insight is that OpenTUI's parser assigns descriptive multi-character names
|
|
103
|
+
* to special keys (like 'backspace', 'up', 'f1') while regular printable characters
|
|
104
|
+
* either have no name (multi-byte input like Chinese) or a single-character name.
|
|
105
|
+
*/
|
|
106
|
+
function isPrintableCharacterKey(key: KeyEvent): boolean {
|
|
107
|
+
const name = key.name
|
|
108
|
+
|
|
109
|
+
// No name = likely multi-byte input (Chinese, Japanese, Korean, etc.) - treat as printable
|
|
110
|
+
if (!name) return true
|
|
111
|
+
|
|
112
|
+
// Single character name = regular ASCII printable (a, b, 1, $, etc.)
|
|
113
|
+
if (name.length === 1) return true
|
|
114
|
+
|
|
115
|
+
// Special case: space key has name 'space' but is printable
|
|
116
|
+
if (name === 'space') return true
|
|
117
|
+
|
|
118
|
+
// Multi-char name = special key (up, f1, backspace, etc.)
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getPrintableKeySequence(key: KeyEvent): string | null {
|
|
123
|
+
if (!key.sequence || key.sequence.length < 1) return null
|
|
124
|
+
if (key.ctrl || key.meta || key.option) return null
|
|
125
|
+
|
|
126
|
+
const keypadValue = getKeypadPrintableSequence(key)
|
|
127
|
+
if (keypadValue !== null) return keypadValue
|
|
128
|
+
|
|
129
|
+
if (!CONTROL_CHAR_REGEX.test(key.sequence) && isPrintableCharacterKey(key)) {
|
|
130
|
+
return key.sequence
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Helper to convert render position (in tab-expanded string) to original text position
|
|
137
|
+
function renderPositionToOriginal(text: string, renderPos: number): number {
|
|
138
|
+
let originalPos = 0
|
|
139
|
+
let currentRenderPos = 0
|
|
140
|
+
|
|
141
|
+
while (originalPos < text.length && currentRenderPos < renderPos) {
|
|
142
|
+
if (text[originalPos] === '\t') {
|
|
143
|
+
currentRenderPos += TAB_WIDTH
|
|
144
|
+
} else {
|
|
145
|
+
currentRenderPos += 1
|
|
146
|
+
}
|
|
147
|
+
originalPos++
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return Math.min(originalPos, text.length)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type KeyWithPreventDefault =
|
|
154
|
+
| {
|
|
155
|
+
preventDefault?: () => void
|
|
156
|
+
}
|
|
157
|
+
| null
|
|
158
|
+
| undefined
|
|
159
|
+
|
|
160
|
+
function preventKeyDefault(key: KeyWithPreventDefault) {
|
|
161
|
+
key?.preventDefault?.()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Helper to check for alt-like modifier keys
|
|
165
|
+
function isAltModifier(key: KeyEvent): boolean {
|
|
166
|
+
const ESC = '\x1b'
|
|
167
|
+
return Boolean(
|
|
168
|
+
key.option ||
|
|
169
|
+
(key.sequence?.length === 2 &&
|
|
170
|
+
key.sequence[0] === ESC &&
|
|
171
|
+
key.sequence[1] !== '['),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Helper type for scrollbox with focus/blur methods (not exposed in OpenTUI types but available at runtime)
|
|
176
|
+
interface FocusableScrollBox {
|
|
177
|
+
focus?: () => void
|
|
178
|
+
blur?: () => void
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface MultilineInputProps {
|
|
182
|
+
value: string
|
|
183
|
+
onChange: (value: InputValue) => void
|
|
184
|
+
onSubmit: () => void
|
|
185
|
+
onKeyIntercept?: (key: KeyEvent) => boolean
|
|
186
|
+
onPaste: (fallbackText?: string) => void
|
|
187
|
+
placeholder?: string
|
|
188
|
+
focused?: boolean
|
|
189
|
+
shouldBlinkCursor?: boolean
|
|
190
|
+
maxHeight?: number
|
|
191
|
+
minHeight?: number
|
|
192
|
+
cursorPosition: number
|
|
193
|
+
showScrollbar?: boolean
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type MultilineInputHandle = {
|
|
197
|
+
focus: () => void
|
|
198
|
+
blur: () => void
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const MultilineInput = forwardRef<
|
|
202
|
+
MultilineInputHandle,
|
|
203
|
+
MultilineInputProps
|
|
204
|
+
>(function MultilineInput(
|
|
205
|
+
{
|
|
206
|
+
value,
|
|
207
|
+
onChange,
|
|
208
|
+
onSubmit,
|
|
209
|
+
onPaste,
|
|
210
|
+
placeholder = '',
|
|
211
|
+
focused = true,
|
|
212
|
+
shouldBlinkCursor,
|
|
213
|
+
maxHeight = 5,
|
|
214
|
+
minHeight = 1,
|
|
215
|
+
onKeyIntercept,
|
|
216
|
+
cursorPosition,
|
|
217
|
+
showScrollbar = false,
|
|
218
|
+
}: MultilineInputProps,
|
|
219
|
+
forwardedRef,
|
|
220
|
+
) {
|
|
221
|
+
const theme = useTheme()
|
|
222
|
+
const renderer = useRenderer()
|
|
223
|
+
const appContext = useAppContext()
|
|
224
|
+
const { keyHandler } = appContext
|
|
225
|
+
const hookBlinkValue = useChatStore((state) => state.isFocusSupported)
|
|
226
|
+
const effectiveShouldBlinkCursor = shouldBlinkCursor ?? hookBlinkValue
|
|
227
|
+
|
|
228
|
+
const scrollBoxRef = useRef<ScrollBoxRenderable | null>(null)
|
|
229
|
+
const [lastActivity, setLastActivity] = useState(Date.now())
|
|
230
|
+
|
|
231
|
+
const stickyColumnRef = useRef<number | null>(null)
|
|
232
|
+
|
|
233
|
+
// Refs to track latest value and cursor position synchronously for IME input handling.
|
|
234
|
+
// When IME sends multiple character events rapidly (e.g., Chinese input), React batches
|
|
235
|
+
// state updates, causing subsequent events to see stale closure values. These refs are
|
|
236
|
+
// updated synchronously to ensure each keystroke builds on the previous one.
|
|
237
|
+
const valueRef = useRef(value)
|
|
238
|
+
const cursorPositionRef = useRef(cursorPosition)
|
|
239
|
+
|
|
240
|
+
// Keep refs current on every render (synchronous assignment avoids useEffect timing issues)
|
|
241
|
+
valueRef.current = value
|
|
242
|
+
cursorPositionRef.current = cursorPosition
|
|
243
|
+
|
|
244
|
+
// Helper to get or set the sticky column for vertical navigation.
|
|
245
|
+
// When stickyColumnRef.current is set, we return it (preserving column across
|
|
246
|
+
// multiple up/down presses). When null, we calculate from current cursor position.
|
|
247
|
+
const getOrSetStickyColumn = useCallback(
|
|
248
|
+
(lineStarts: number[], cursorIsChar: boolean): number => {
|
|
249
|
+
if (stickyColumnRef.current != null) {
|
|
250
|
+
return stickyColumnRef.current
|
|
251
|
+
}
|
|
252
|
+
const lineIndex = lineStarts.findLastIndex(
|
|
253
|
+
(lineStart) => lineStart <= cursorPosition,
|
|
254
|
+
)
|
|
255
|
+
const column =
|
|
256
|
+
lineIndex === -1
|
|
257
|
+
? 0
|
|
258
|
+
: cursorPosition - lineStarts[lineIndex] + (cursorIsChar ? -1 : 0)
|
|
259
|
+
stickyColumnRef.current = Math.max(0, column)
|
|
260
|
+
return stickyColumnRef.current
|
|
261
|
+
},
|
|
262
|
+
[cursorPosition],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// Update last activity on value or cursor changes
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
setLastActivity(Date.now())
|
|
268
|
+
}, [value, cursorPosition])
|
|
269
|
+
|
|
270
|
+
const textRef = useRef<TextRenderable | null>(null)
|
|
271
|
+
|
|
272
|
+
const lineInfo = textRef.current
|
|
273
|
+
? (
|
|
274
|
+
(textRef.current satisfies TextRenderable as any)
|
|
275
|
+
.textBufferView as TextBufferView
|
|
276
|
+
).lineInfo
|
|
277
|
+
: null
|
|
278
|
+
|
|
279
|
+
// Focus/blur scrollbox when focused prop changes
|
|
280
|
+
const prevFocusedRef = useRef(false)
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (focused && !prevFocusedRef.current) {
|
|
283
|
+
(scrollBoxRef.current as FocusableScrollBox | null)?.focus?.()
|
|
284
|
+
} else if (!focused && prevFocusedRef.current) {
|
|
285
|
+
(scrollBoxRef.current as FocusableScrollBox | null)?.blur?.()
|
|
286
|
+
}
|
|
287
|
+
prevFocusedRef.current = focused
|
|
288
|
+
}, [focused])
|
|
289
|
+
|
|
290
|
+
// Expose focus/blur for imperative use cases
|
|
291
|
+
useImperativeHandle(
|
|
292
|
+
forwardedRef,
|
|
293
|
+
() => ({
|
|
294
|
+
focus: () => {
|
|
295
|
+
(scrollBoxRef.current as FocusableScrollBox | null)?.focus?.()
|
|
296
|
+
},
|
|
297
|
+
blur: () => {
|
|
298
|
+
(scrollBoxRef.current as FocusableScrollBox | null)?.blur?.()
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
[],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const cursorRow = lineInfo
|
|
305
|
+
? Math.max(
|
|
306
|
+
0,
|
|
307
|
+
lineInfo.lineStartCols.findLastIndex(
|
|
308
|
+
(lineStart) => lineStart <= cursorPosition,
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
: 0
|
|
312
|
+
|
|
313
|
+
// Auto-scroll to cursor when content changes
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const scrollBox = scrollBoxRef.current
|
|
316
|
+
if (scrollBox && focused) {
|
|
317
|
+
const scrollPosition = clamp(
|
|
318
|
+
scrollBox.verticalScrollBar.scrollPosition,
|
|
319
|
+
Math.max(0, cursorRow - scrollBox.viewport.height + 1),
|
|
320
|
+
Math.min(scrollBox.scrollHeight - scrollBox.viewport.height, cursorRow),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
scrollBox.verticalScrollBar.scrollPosition = scrollPosition
|
|
324
|
+
}
|
|
325
|
+
}, [scrollBoxRef.current, cursorPosition, focused, cursorRow])
|
|
326
|
+
|
|
327
|
+
// Helper to get current selection in original text coordinates
|
|
328
|
+
const getSelectionRange = useCallback((): { start: number; end: number } | null => {
|
|
329
|
+
const textBufferView = (textRef.current as any)?.textBufferView
|
|
330
|
+
if (!textBufferView?.hasSelection?.() || !textBufferView?.getSelection) {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
const selection = textBufferView.getSelection()
|
|
334
|
+
if (!selection) return null
|
|
335
|
+
|
|
336
|
+
// Convert from render positions to original text positions
|
|
337
|
+
const start = renderPositionToOriginal(value, Math.min(selection.start, selection.end))
|
|
338
|
+
const end = renderPositionToOriginal(value, Math.max(selection.start, selection.end))
|
|
339
|
+
|
|
340
|
+
if (start === end) return null
|
|
341
|
+
return { start, end }
|
|
342
|
+
}, [value])
|
|
343
|
+
|
|
344
|
+
// Helper to clear the current selection
|
|
345
|
+
const clearSelection = useCallback(() => {
|
|
346
|
+
// Use renderer's clearSelection for proper visual clearing
|
|
347
|
+
;(renderer as any)?.clearSelection?.()
|
|
348
|
+
}, [renderer])
|
|
349
|
+
|
|
350
|
+
// Helper to delete selected text and return new value and cursor position
|
|
351
|
+
const deleteSelection = useCallback((): { newValue: string; newCursor: number } | null => {
|
|
352
|
+
const selection = getSelectionRange()
|
|
353
|
+
if (!selection) return null
|
|
354
|
+
|
|
355
|
+
const newValue = value.slice(0, selection.start) + value.slice(selection.end)
|
|
356
|
+
clearSelection()
|
|
357
|
+
return { newValue, newCursor: selection.start }
|
|
358
|
+
}, [value, getSelectionRange, clearSelection])
|
|
359
|
+
|
|
360
|
+
// Helper to handle selection deletion and call onChange if selection existed
|
|
361
|
+
// Returns true if selection was deleted, false otherwise
|
|
362
|
+
const handleSelectionDeletion = useCallback((): boolean => {
|
|
363
|
+
const deleted = deleteSelection()
|
|
364
|
+
if (deleted) {
|
|
365
|
+
onChange({
|
|
366
|
+
text: deleted.newValue,
|
|
367
|
+
cursorPosition: deleted.newCursor,
|
|
368
|
+
lastEditDueToNav: false,
|
|
369
|
+
})
|
|
370
|
+
return true
|
|
371
|
+
}
|
|
372
|
+
return false
|
|
373
|
+
}, [deleteSelection, onChange])
|
|
374
|
+
|
|
375
|
+
const insertTextAtCursor = useCallback(
|
|
376
|
+
(textToInsert: string) => {
|
|
377
|
+
if (!textToInsert) return
|
|
378
|
+
|
|
379
|
+
// Check if there's a selection to replace
|
|
380
|
+
const selection = getSelectionRange()
|
|
381
|
+
if (selection) {
|
|
382
|
+
// Replace selected text with the new text
|
|
383
|
+
clearSelection()
|
|
384
|
+
// Read from refs which have the latest values (updated synchronously below)
|
|
385
|
+
const currentValue = valueRef.current
|
|
386
|
+
const newValue =
|
|
387
|
+
currentValue.slice(0, selection.start) +
|
|
388
|
+
textToInsert +
|
|
389
|
+
currentValue.slice(selection.end)
|
|
390
|
+
const newCursor = selection.start + textToInsert.length
|
|
391
|
+
|
|
392
|
+
// Update refs synchronously BEFORE calling onChange - critical for IME input
|
|
393
|
+
// where multiple characters may arrive before React processes state updates
|
|
394
|
+
valueRef.current = newValue
|
|
395
|
+
cursorPositionRef.current = newCursor
|
|
396
|
+
|
|
397
|
+
onChange({
|
|
398
|
+
text: newValue,
|
|
399
|
+
cursorPosition: newCursor,
|
|
400
|
+
lastEditDueToNav: false,
|
|
401
|
+
})
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// No selection, insert at cursor
|
|
406
|
+
// Read from refs to get latest state (handles rapid IME input)
|
|
407
|
+
const currentValue = valueRef.current
|
|
408
|
+
const currentCursor = cursorPositionRef.current
|
|
409
|
+
const newValue =
|
|
410
|
+
currentValue.slice(0, currentCursor) +
|
|
411
|
+
textToInsert +
|
|
412
|
+
currentValue.slice(currentCursor)
|
|
413
|
+
const newCursor = currentCursor + textToInsert.length
|
|
414
|
+
|
|
415
|
+
// Update refs synchronously BEFORE calling onChange - critical for IME input
|
|
416
|
+
// where multiple characters may arrive before React processes state updates
|
|
417
|
+
valueRef.current = newValue
|
|
418
|
+
cursorPositionRef.current = newCursor
|
|
419
|
+
|
|
420
|
+
onChange({
|
|
421
|
+
text: newValue,
|
|
422
|
+
cursorPosition: newCursor,
|
|
423
|
+
lastEditDueToNav: false,
|
|
424
|
+
})
|
|
425
|
+
},
|
|
426
|
+
[onChange, getSelectionRange, clearSelection],
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const moveCursor = useCallback(
|
|
430
|
+
(nextPosition: number) => {
|
|
431
|
+
const clamped = Math.max(0, Math.min(value.length, nextPosition))
|
|
432
|
+
if (clamped === cursorPosition) return
|
|
433
|
+
onChange({
|
|
434
|
+
text: value,
|
|
435
|
+
cursorPosition: clamped,
|
|
436
|
+
lastEditDueToNav: false,
|
|
437
|
+
})
|
|
438
|
+
},
|
|
439
|
+
[cursorPosition, onChange, value],
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
// Handle mouse clicks to position cursor
|
|
443
|
+
const handleMouseDown = useCallback(
|
|
444
|
+
(event: MouseEvent) => {
|
|
445
|
+
if (!focused) return
|
|
446
|
+
|
|
447
|
+
// Clear sticky column since this is not up/down navigation
|
|
448
|
+
stickyColumnRef.current = null
|
|
449
|
+
|
|
450
|
+
const scrollBox = scrollBoxRef.current
|
|
451
|
+
if (!scrollBox) return
|
|
452
|
+
|
|
453
|
+
const lineStarts = lineInfo?.lineStartCols ?? [0]
|
|
454
|
+
|
|
455
|
+
const viewport = (scrollBox as any).viewport
|
|
456
|
+
const viewportTop = Number(viewport?.y ?? 0)
|
|
457
|
+
const viewportLeft = Number(viewport?.x ?? 0)
|
|
458
|
+
|
|
459
|
+
// Get click position, accounting for scroll
|
|
460
|
+
const scrollPosition = scrollBox.verticalScrollBar?.scrollPosition ?? 0
|
|
461
|
+
const clickRowInViewport = Math.floor(event.y - viewportTop)
|
|
462
|
+
const clickRow = clickRowInViewport + scrollPosition
|
|
463
|
+
|
|
464
|
+
// Find which visual line was clicked
|
|
465
|
+
const lineIndex = Math.min(
|
|
466
|
+
Math.max(0, clickRow),
|
|
467
|
+
lineStarts.length - 1,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
// Get the character range for this line
|
|
471
|
+
const lineStartChar = lineStarts[lineIndex]
|
|
472
|
+
const lineEndChar = lineStarts[lineIndex + 1] ?? value.length
|
|
473
|
+
|
|
474
|
+
// Convert click x to character position, accounting for tabs
|
|
475
|
+
const clickCol = Math.max(0, Math.floor(event.x - viewportLeft))
|
|
476
|
+
|
|
477
|
+
let visualCol = 0
|
|
478
|
+
let charIndex = lineStartChar
|
|
479
|
+
|
|
480
|
+
while (charIndex < lineEndChar && visualCol < clickCol) {
|
|
481
|
+
const char = value[charIndex]
|
|
482
|
+
if (char === '\t') {
|
|
483
|
+
visualCol += TAB_WIDTH
|
|
484
|
+
} else if (char === '\n') {
|
|
485
|
+
break
|
|
486
|
+
} else {
|
|
487
|
+
visualCol += 1
|
|
488
|
+
}
|
|
489
|
+
charIndex++
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Clamp to valid range
|
|
493
|
+
const newCursorPosition = Math.min(charIndex, value.length)
|
|
494
|
+
|
|
495
|
+
// Update cursor position if changed
|
|
496
|
+
if (newCursorPosition !== cursorPosition) {
|
|
497
|
+
onChange({
|
|
498
|
+
text: value,
|
|
499
|
+
cursorPosition: newCursorPosition,
|
|
500
|
+
lastEditDueToNav: false,
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
[focused, lineInfo, value, cursorPosition, onChange],
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
const isPlaceholder = value.length === 0 && placeholder.length > 0
|
|
508
|
+
const displayValue = isPlaceholder ? placeholder : value
|
|
509
|
+
const showCursor = focused
|
|
510
|
+
|
|
511
|
+
// Replace tabs with spaces for proper rendering
|
|
512
|
+
const displayValueForRendering = displayValue.replace(
|
|
513
|
+
/\t/g,
|
|
514
|
+
' '.repeat(TAB_WIDTH),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
// Calculate cursor position in the expanded string (accounting for tabs)
|
|
518
|
+
let renderCursorPosition = 0
|
|
519
|
+
for (let i = 0; i < cursorPosition && i < displayValue.length; i++) {
|
|
520
|
+
renderCursorPosition += displayValue[i] === '\t' ? TAB_WIDTH : 1
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const { beforeCursor, afterCursor, activeChar, shouldHighlight } = (() => {
|
|
524
|
+
if (!showCursor) {
|
|
525
|
+
return {
|
|
526
|
+
beforeCursor: '',
|
|
527
|
+
afterCursor: '',
|
|
528
|
+
activeChar: ' ',
|
|
529
|
+
shouldHighlight: false,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const beforeCursor = displayValueForRendering.slice(0, renderCursorPosition)
|
|
534
|
+
const afterCursor = displayValueForRendering.slice(renderCursorPosition)
|
|
535
|
+
const activeChar = afterCursor.charAt(0) || ' '
|
|
536
|
+
const shouldHighlight =
|
|
537
|
+
!isPlaceholder &&
|
|
538
|
+
renderCursorPosition < displayValueForRendering.length &&
|
|
539
|
+
displayValue[cursorPosition] !== '\n' &&
|
|
540
|
+
displayValue[cursorPosition] !== '\t'
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
beforeCursor,
|
|
544
|
+
afterCursor,
|
|
545
|
+
activeChar,
|
|
546
|
+
shouldHighlight,
|
|
547
|
+
}
|
|
548
|
+
})()
|
|
549
|
+
|
|
550
|
+
// --- Keyboard Handler Helpers ---
|
|
551
|
+
|
|
552
|
+
// Handle enter/newline keys
|
|
553
|
+
const handleEnterKeys = useCallback(
|
|
554
|
+
(key: KeyEvent): boolean => {
|
|
555
|
+
const lowerKeyName = (key.name ?? '').toLowerCase()
|
|
556
|
+
const keypadEnter = isKeypadEnter(key)
|
|
557
|
+
const isReturnOrEnter =
|
|
558
|
+
key.name === 'return' || key.name === 'enter' || keypadEnter
|
|
559
|
+
|
|
560
|
+
markReturnKeySeenForKey(key)
|
|
561
|
+
|
|
562
|
+
const linefeedIsEnter = lowerKeyName === 'linefeed' && isLinefeedActingAsEnter()
|
|
563
|
+
const isEnterKey = isReturnOrEnter || linefeedIsEnter
|
|
564
|
+
|
|
565
|
+
const isCtrlJ =
|
|
566
|
+
(lowerKeyName === 'linefeed' && !linefeedIsEnter) ||
|
|
567
|
+
(key.ctrl &&
|
|
568
|
+
!key.meta &&
|
|
569
|
+
!key.option &&
|
|
570
|
+
lowerKeyName === 'j')
|
|
571
|
+
|
|
572
|
+
// Only handle Enter and Ctrl+J here
|
|
573
|
+
if (!isEnterKey && !isCtrlJ) return false
|
|
574
|
+
|
|
575
|
+
const isAltLikeModifier = isAltModifier(key)
|
|
576
|
+
// Kitty-protocol keyboards encode plain Enter as an escape sequence
|
|
577
|
+
// (e.g. \x1b[13u) but report modifiers reliably, so the escape-prefix
|
|
578
|
+
// and raw \r/\n heuristics (which exist to catch legacy Alt+Enter)
|
|
579
|
+
// must not apply to kitty events.
|
|
580
|
+
const isKittyKey = key.source === 'kitty'
|
|
581
|
+
const hasEscapePrefix =
|
|
582
|
+
!isKittyKey &&
|
|
583
|
+
typeof key.sequence === 'string' &&
|
|
584
|
+
key.sequence.length > 0 &&
|
|
585
|
+
key.sequence.charCodeAt(0) === 0x1b
|
|
586
|
+
const hasBackslashBeforeCursor =
|
|
587
|
+
cursorPosition > 0 && value[cursorPosition - 1] === '\\'
|
|
588
|
+
|
|
589
|
+
// Plain Enter: no modifiers, sequence is '\r' (macOS) or '\n' (Linux)
|
|
590
|
+
const isPlainEnter =
|
|
591
|
+
isEnterKey &&
|
|
592
|
+
!key.shift &&
|
|
593
|
+
!key.ctrl &&
|
|
594
|
+
!key.meta &&
|
|
595
|
+
!key.option &&
|
|
596
|
+
!isAltLikeModifier &&
|
|
597
|
+
(!hasEscapePrefix || keypadEnter) &&
|
|
598
|
+
(key.sequence === '\r' ||
|
|
599
|
+
key.sequence === '\n' ||
|
|
600
|
+
keypadEnter ||
|
|
601
|
+
isKittyKey) &&
|
|
602
|
+
!hasBackslashBeforeCursor
|
|
603
|
+
const isShiftEnter = isEnterKey && Boolean(key.shift)
|
|
604
|
+
const isOptionEnter =
|
|
605
|
+
isEnterKey && !keypadEnter && (isAltLikeModifier || hasEscapePrefix)
|
|
606
|
+
const isBackslashEnter = isEnterKey && hasBackslashBeforeCursor
|
|
607
|
+
|
|
608
|
+
const shouldInsertNewline =
|
|
609
|
+
isCtrlJ || isShiftEnter || isOptionEnter || isBackslashEnter
|
|
610
|
+
|
|
611
|
+
if (shouldInsertNewline) {
|
|
612
|
+
preventKeyDefault(key)
|
|
613
|
+
|
|
614
|
+
// For backslash+Enter, remove the backslash and insert newline
|
|
615
|
+
if (isBackslashEnter) {
|
|
616
|
+
const newValue =
|
|
617
|
+
value.slice(0, cursorPosition - 1) +
|
|
618
|
+
'\n' +
|
|
619
|
+
value.slice(cursorPosition)
|
|
620
|
+
onChange({
|
|
621
|
+
text: newValue,
|
|
622
|
+
cursorPosition,
|
|
623
|
+
lastEditDueToNav: false,
|
|
624
|
+
})
|
|
625
|
+
return true
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// For other newline shortcuts (Shift+Enter, Option+Enter, Ctrl+J), just insert newline
|
|
629
|
+
const newValue =
|
|
630
|
+
value.slice(0, cursorPosition) + '\n' + value.slice(cursorPosition)
|
|
631
|
+
onChange({
|
|
632
|
+
text: newValue,
|
|
633
|
+
cursorPosition: cursorPosition + 1,
|
|
634
|
+
lastEditDueToNav: false,
|
|
635
|
+
})
|
|
636
|
+
return true
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (isPlainEnter) {
|
|
640
|
+
preventKeyDefault(key)
|
|
641
|
+
onSubmit()
|
|
642
|
+
return true
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return false
|
|
646
|
+
},
|
|
647
|
+
[value, cursorPosition, onChange, onSubmit],
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
// Handle deletion keys (backspace, delete, ctrl+h, ctrl+d, word/line deletion)
|
|
651
|
+
const handleDeletionKeys = useCallback(
|
|
652
|
+
(key: KeyEvent): boolean => {
|
|
653
|
+
const lowerKeyName = (key.name ?? '').toLowerCase()
|
|
654
|
+
const isAltLikeModifier = isAltModifier(key)
|
|
655
|
+
const lineStart = findLineStart(value, cursorPosition)
|
|
656
|
+
const lineEnd = findLineEnd(value, cursorPosition)
|
|
657
|
+
const wordStart = findPreviousWordBoundary(value, cursorPosition)
|
|
658
|
+
const wordEnd = findNextWordBoundary(value, cursorPosition)
|
|
659
|
+
|
|
660
|
+
// Ctrl+U: Delete from cursor to beginning of current VISUAL line
|
|
661
|
+
if (key.ctrl && lowerKeyName === 'u' && !key.meta && !key.option) {
|
|
662
|
+
preventKeyDefault(key)
|
|
663
|
+
if (handleSelectionDeletion()) return true
|
|
664
|
+
const visualLineStart = lineInfo?.lineStartCols?.[cursorRow] ?? lineStart
|
|
665
|
+
|
|
666
|
+
if (cursorPosition > visualLineStart) {
|
|
667
|
+
const newValue =
|
|
668
|
+
value.slice(0, visualLineStart) + value.slice(cursorPosition)
|
|
669
|
+
onChange({
|
|
670
|
+
text: newValue,
|
|
671
|
+
cursorPosition: visualLineStart,
|
|
672
|
+
lastEditDueToNav: false,
|
|
673
|
+
})
|
|
674
|
+
} else if (cursorPosition > 0) {
|
|
675
|
+
const newValue =
|
|
676
|
+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
|
|
677
|
+
onChange({
|
|
678
|
+
text: newValue,
|
|
679
|
+
cursorPosition: cursorPosition - 1,
|
|
680
|
+
lastEditDueToNav: false,
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
return true
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Alt+Backspace or Ctrl+W: Delete word backward
|
|
687
|
+
if (
|
|
688
|
+
(key.name === 'backspace' && isAltLikeModifier) ||
|
|
689
|
+
(key.ctrl && lowerKeyName === 'w')
|
|
690
|
+
) {
|
|
691
|
+
preventKeyDefault(key)
|
|
692
|
+
if (handleSelectionDeletion()) return true
|
|
693
|
+
const newValue =
|
|
694
|
+
value.slice(0, wordStart) + value.slice(cursorPosition)
|
|
695
|
+
onChange({
|
|
696
|
+
text: newValue,
|
|
697
|
+
cursorPosition: wordStart,
|
|
698
|
+
lastEditDueToNav: false,
|
|
699
|
+
})
|
|
700
|
+
return true
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Cmd+Delete: Delete to line start
|
|
704
|
+
if (key.name === 'delete' && key.meta && !isAltLikeModifier) {
|
|
705
|
+
preventKeyDefault(key)
|
|
706
|
+
if (handleSelectionDeletion()) return true
|
|
707
|
+
const originalValue = value
|
|
708
|
+
let newValue = originalValue
|
|
709
|
+
let nextCursor = cursorPosition
|
|
710
|
+
|
|
711
|
+
if (cursorPosition > 0) {
|
|
712
|
+
if (
|
|
713
|
+
cursorPosition === lineStart &&
|
|
714
|
+
value[cursorPosition - 1] === '\n'
|
|
715
|
+
) {
|
|
716
|
+
newValue =
|
|
717
|
+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
|
|
718
|
+
nextCursor = cursorPosition - 1
|
|
719
|
+
} else {
|
|
720
|
+
newValue = value.slice(0, lineStart) + value.slice(cursorPosition)
|
|
721
|
+
nextCursor = lineStart
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (newValue === originalValue && cursorPosition > 0) {
|
|
726
|
+
newValue =
|
|
727
|
+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
|
|
728
|
+
nextCursor = cursorPosition - 1
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (newValue !== originalValue) {
|
|
732
|
+
onChange({
|
|
733
|
+
text: newValue,
|
|
734
|
+
cursorPosition: nextCursor,
|
|
735
|
+
lastEditDueToNav: false,
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
return true
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Alt+Delete: Delete word forward
|
|
742
|
+
if (key.name === 'delete' && isAltLikeModifier) {
|
|
743
|
+
preventKeyDefault(key)
|
|
744
|
+
if (handleSelectionDeletion()) return true
|
|
745
|
+
const newValue = value.slice(0, cursorPosition) + value.slice(wordEnd)
|
|
746
|
+
onChange({
|
|
747
|
+
text: newValue,
|
|
748
|
+
cursorPosition,
|
|
749
|
+
lastEditDueToNav: false,
|
|
750
|
+
})
|
|
751
|
+
return true
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Ctrl+K: Delete to line end
|
|
755
|
+
if (key.ctrl && lowerKeyName === 'k' && !key.meta && !key.option) {
|
|
756
|
+
preventKeyDefault(key)
|
|
757
|
+
if (handleSelectionDeletion()) return true
|
|
758
|
+
const newValue = value.slice(0, cursorPosition) + value.slice(lineEnd)
|
|
759
|
+
onChange({ text: newValue, cursorPosition, lastEditDueToNav: false })
|
|
760
|
+
return true
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Ctrl+H: Delete char backward (Emacs)
|
|
764
|
+
if (key.ctrl && lowerKeyName === 'h' && !key.meta && !key.option) {
|
|
765
|
+
preventKeyDefault(key)
|
|
766
|
+
if (handleSelectionDeletion()) return true
|
|
767
|
+
if (cursorPosition > 0) {
|
|
768
|
+
const newValue =
|
|
769
|
+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
|
|
770
|
+
onChange({
|
|
771
|
+
text: newValue,
|
|
772
|
+
cursorPosition: cursorPosition - 1,
|
|
773
|
+
lastEditDueToNav: false,
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
return true
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Ctrl+D: Delete char forward (Emacs)
|
|
780
|
+
if (key.ctrl && lowerKeyName === 'd' && !key.meta && !key.option) {
|
|
781
|
+
preventKeyDefault(key)
|
|
782
|
+
if (handleSelectionDeletion()) return true
|
|
783
|
+
if (cursorPosition < value.length) {
|
|
784
|
+
const newValue =
|
|
785
|
+
value.slice(0, cursorPosition) + value.slice(cursorPosition + 1)
|
|
786
|
+
onChange({
|
|
787
|
+
text: newValue,
|
|
788
|
+
cursorPosition,
|
|
789
|
+
lastEditDueToNav: false,
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
return true
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Basic Backspace (no modifiers)
|
|
796
|
+
if (key.name === 'backspace' && !key.ctrl && !key.meta && !key.option) {
|
|
797
|
+
preventKeyDefault(key)
|
|
798
|
+
if (handleSelectionDeletion()) return true
|
|
799
|
+
if (cursorPosition > 0) {
|
|
800
|
+
const newValue =
|
|
801
|
+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
|
|
802
|
+
onChange({
|
|
803
|
+
text: newValue,
|
|
804
|
+
cursorPosition: cursorPosition - 1,
|
|
805
|
+
lastEditDueToNav: false,
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
return true
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Basic Delete (no modifiers)
|
|
812
|
+
if (key.name === 'delete' && !key.ctrl && !key.meta && !key.option) {
|
|
813
|
+
preventKeyDefault(key)
|
|
814
|
+
if (handleSelectionDeletion()) return true
|
|
815
|
+
if (cursorPosition < value.length) {
|
|
816
|
+
const newValue =
|
|
817
|
+
value.slice(0, cursorPosition) + value.slice(cursorPosition + 1)
|
|
818
|
+
onChange({
|
|
819
|
+
text: newValue,
|
|
820
|
+
cursorPosition,
|
|
821
|
+
lastEditDueToNav: false,
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
return true
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return false
|
|
828
|
+
},
|
|
829
|
+
[value, cursorPosition, onChange, lineInfo, cursorRow, handleSelectionDeletion],
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
// Handle navigation keys (arrows, home, end, word navigation, emacs bindings)
|
|
833
|
+
const handleNavigationKeys = useCallback(
|
|
834
|
+
(key: KeyEvent): boolean => {
|
|
835
|
+
const lowerKeyName = (key.name ?? '').toLowerCase()
|
|
836
|
+
const isAltLikeModifier = isAltModifier(key)
|
|
837
|
+
const logicalLineStart = findLineStart(value, cursorPosition)
|
|
838
|
+
const logicalLineEnd = findLineEnd(value, cursorPosition)
|
|
839
|
+
const wordStart = findPreviousWordBoundary(value, cursorPosition)
|
|
840
|
+
const wordEnd = findNextWordBoundary(value, cursorPosition)
|
|
841
|
+
|
|
842
|
+
// Read lineInfo inside the callback to get current value (not stale from closure)
|
|
843
|
+
const currentLineInfo = textRef.current
|
|
844
|
+
? ((textRef.current as any).textBufferView as TextBufferView)?.lineInfo
|
|
845
|
+
: null
|
|
846
|
+
|
|
847
|
+
// Calculate visual line boundaries from lineInfo (accounts for word wrap)
|
|
848
|
+
// Fall back to logical line boundaries if visual info is unavailable
|
|
849
|
+
const lineStarts = currentLineInfo?.lineStartCols ?? []
|
|
850
|
+
const visualLineIndex = lineStarts.findLastIndex(
|
|
851
|
+
(start) => start <= cursorPosition,
|
|
852
|
+
)
|
|
853
|
+
const visualLineStart = visualLineIndex >= 0
|
|
854
|
+
? lineStarts[visualLineIndex]
|
|
855
|
+
: logicalLineStart
|
|
856
|
+
const visualLineEnd = lineStarts[visualLineIndex + 1] !== undefined
|
|
857
|
+
? lineStarts[visualLineIndex + 1] - 1
|
|
858
|
+
: logicalLineEnd
|
|
859
|
+
|
|
860
|
+
// Alt+Left/B: Word left
|
|
861
|
+
if (
|
|
862
|
+
isAltLikeModifier &&
|
|
863
|
+
(key.name === 'left' || lowerKeyName === 'b')
|
|
864
|
+
) {
|
|
865
|
+
preventKeyDefault(key)
|
|
866
|
+
onChange({
|
|
867
|
+
text: value,
|
|
868
|
+
cursorPosition: wordStart,
|
|
869
|
+
lastEditDueToNav: false,
|
|
870
|
+
})
|
|
871
|
+
return true
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Alt+Right/F: Word right
|
|
875
|
+
if (
|
|
876
|
+
isAltLikeModifier &&
|
|
877
|
+
(key.name === 'right' || lowerKeyName === 'f')
|
|
878
|
+
) {
|
|
879
|
+
preventKeyDefault(key)
|
|
880
|
+
onChange({
|
|
881
|
+
text: value,
|
|
882
|
+
cursorPosition: wordEnd,
|
|
883
|
+
lastEditDueToNav: false,
|
|
884
|
+
})
|
|
885
|
+
return true
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Cmd+Left, Ctrl+A, or Home: Line start
|
|
889
|
+
if (
|
|
890
|
+
(key.meta && key.name === 'left' && !isAltLikeModifier) ||
|
|
891
|
+
(key.ctrl && lowerKeyName === 'a' && !key.meta && !key.option) ||
|
|
892
|
+
(key.name === 'home' && !key.ctrl && !key.meta)
|
|
893
|
+
) {
|
|
894
|
+
preventKeyDefault(key)
|
|
895
|
+
onChange({
|
|
896
|
+
text: value,
|
|
897
|
+
cursorPosition: visualLineStart,
|
|
898
|
+
lastEditDueToNav: false,
|
|
899
|
+
})
|
|
900
|
+
return true
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Cmd+Right, Ctrl+E, or End: Line end
|
|
904
|
+
if (
|
|
905
|
+
(key.meta && key.name === 'right' && !isAltLikeModifier) ||
|
|
906
|
+
(key.ctrl && lowerKeyName === 'e' && !key.meta && !key.option) ||
|
|
907
|
+
(key.name === 'end' && !key.ctrl && !key.meta)
|
|
908
|
+
) {
|
|
909
|
+
preventKeyDefault(key)
|
|
910
|
+
onChange({
|
|
911
|
+
text: value,
|
|
912
|
+
cursorPosition: visualLineEnd,
|
|
913
|
+
lastEditDueToNav: false,
|
|
914
|
+
})
|
|
915
|
+
return true
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Cmd+Up or Ctrl+Home: Document start
|
|
919
|
+
if (
|
|
920
|
+
(key.meta && key.name === 'up') ||
|
|
921
|
+
(key.ctrl && key.name === 'home')
|
|
922
|
+
) {
|
|
923
|
+
preventKeyDefault(key)
|
|
924
|
+
onChange({ text: value, cursorPosition: 0, lastEditDueToNav: false })
|
|
925
|
+
return true
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Cmd+Down or Ctrl+End: Document end
|
|
929
|
+
if (
|
|
930
|
+
(key.meta && key.name === 'down') ||
|
|
931
|
+
(key.ctrl && key.name === 'end')
|
|
932
|
+
) {
|
|
933
|
+
preventKeyDefault(key)
|
|
934
|
+
onChange({
|
|
935
|
+
text: value,
|
|
936
|
+
cursorPosition: value.length,
|
|
937
|
+
lastEditDueToNav: false,
|
|
938
|
+
})
|
|
939
|
+
return true
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Ctrl+B: Backward char (Emacs)
|
|
943
|
+
if (key.ctrl && lowerKeyName === 'b' && !key.meta && !key.option) {
|
|
944
|
+
preventKeyDefault(key)
|
|
945
|
+
onChange({
|
|
946
|
+
text: value,
|
|
947
|
+
cursorPosition: cursorPosition - 1,
|
|
948
|
+
lastEditDueToNav: false,
|
|
949
|
+
})
|
|
950
|
+
return true
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Ctrl+F: Forward char (Emacs)
|
|
954
|
+
if (key.ctrl && lowerKeyName === 'f' && !key.meta && !key.option) {
|
|
955
|
+
preventKeyDefault(key)
|
|
956
|
+
onChange({
|
|
957
|
+
text: value,
|
|
958
|
+
cursorPosition: Math.min(value.length, cursorPosition + 1),
|
|
959
|
+
lastEditDueToNav: false,
|
|
960
|
+
})
|
|
961
|
+
return true
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Left arrow (no modifiers)
|
|
965
|
+
if (key.name === 'left' && !key.ctrl && !key.meta && !key.option) {
|
|
966
|
+
preventKeyDefault(key)
|
|
967
|
+
moveCursor(cursorPosition - 1)
|
|
968
|
+
return true
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Right arrow (no modifiers)
|
|
972
|
+
if (key.name === 'right' && !key.ctrl && !key.meta && !key.option) {
|
|
973
|
+
preventKeyDefault(key)
|
|
974
|
+
moveCursor(cursorPosition + 1)
|
|
975
|
+
return true
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Up arrow (no modifiers)
|
|
979
|
+
if (key.name === 'up' && !key.ctrl && !key.meta && !key.option) {
|
|
980
|
+
preventKeyDefault(key)
|
|
981
|
+
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
|
|
982
|
+
onChange({
|
|
983
|
+
text: value,
|
|
984
|
+
cursorPosition: calculateNewCursorPosition({
|
|
985
|
+
cursorPosition,
|
|
986
|
+
lineStarts,
|
|
987
|
+
cursorIsChar: !shouldHighlight,
|
|
988
|
+
direction: 'up',
|
|
989
|
+
desiredIndex,
|
|
990
|
+
}),
|
|
991
|
+
lastEditDueToNav: false,
|
|
992
|
+
})
|
|
993
|
+
return true
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Down arrow (no modifiers)
|
|
997
|
+
if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) {
|
|
998
|
+
preventKeyDefault(key)
|
|
999
|
+
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
|
|
1000
|
+
onChange({
|
|
1001
|
+
text: value,
|
|
1002
|
+
cursorPosition: calculateNewCursorPosition({
|
|
1003
|
+
cursorPosition,
|
|
1004
|
+
lineStarts,
|
|
1005
|
+
cursorIsChar: !shouldHighlight,
|
|
1006
|
+
direction: 'down',
|
|
1007
|
+
desiredIndex,
|
|
1008
|
+
}),
|
|
1009
|
+
lastEditDueToNav: false,
|
|
1010
|
+
})
|
|
1011
|
+
return true
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return false
|
|
1015
|
+
},
|
|
1016
|
+
[value, cursorPosition, onChange, moveCursor, shouldHighlight, getOrSetStickyColumn],
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
// Handle character input (regular chars, tab, and IME/multi-byte input)
|
|
1020
|
+
const handleCharacterInput = useCallback(
|
|
1021
|
+
(key: KeyEvent): boolean => {
|
|
1022
|
+
// Tab: let higher-level keyboard handlers (like chat keyboard shortcuts) handle it
|
|
1023
|
+
if (
|
|
1024
|
+
key.name === 'tab' &&
|
|
1025
|
+
key.sequence &&
|
|
1026
|
+
!key.shift &&
|
|
1027
|
+
!key.ctrl &&
|
|
1028
|
+
!key.meta &&
|
|
1029
|
+
!key.option
|
|
1030
|
+
) {
|
|
1031
|
+
// Don't insert a literal tab character here; allow global keyboard handlers to process it
|
|
1032
|
+
return false
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Character input (including multi-byte characters from IME like Chinese, Japanese, Korean)
|
|
1036
|
+
const textToInsert = getPrintableKeySequence(key)
|
|
1037
|
+
if (textToInsert !== null) {
|
|
1038
|
+
preventKeyDefault(key)
|
|
1039
|
+
insertTextAtCursor(textToInsert)
|
|
1040
|
+
return true
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return false
|
|
1044
|
+
},
|
|
1045
|
+
[insertTextAtCursor],
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
// Increase StdinParser timeout from default 10ms to 100ms.
|
|
1049
|
+
// Some terminals (Ghostty, iTerm2, VS Code) split bracketed paste sequences
|
|
1050
|
+
// across multiple stdin reads when drag-dropping files. The default 10ms
|
|
1051
|
+
// timeout causes the parser to flush partial escape sequences as keypresses,
|
|
1052
|
+
// corrupting paste detection. 100ms is still fast for keyboard input but
|
|
1053
|
+
// gives enough time for split paste sequences to arrive.
|
|
1054
|
+
useEffect(() => {
|
|
1055
|
+
const cliRenderer = appContext.renderer as Record<string, unknown> | null
|
|
1056
|
+
const stdinBuffer = cliRenderer?._stdinBuffer as Record<string, unknown> | undefined
|
|
1057
|
+
if (stdinBuffer && typeof stdinBuffer.timeoutMs === 'number') {
|
|
1058
|
+
stdinBuffer.timeoutMs = 100
|
|
1059
|
+
}
|
|
1060
|
+
}, [appContext])
|
|
1061
|
+
|
|
1062
|
+
// Global paste event listener — catches paste events (e.g. from drag-and-drop)
|
|
1063
|
+
// at the global level, plus a scrollbox-level backup. Some terminals may not
|
|
1064
|
+
// deliver paste events reliably via one mechanism alone, so we use both with
|
|
1065
|
+
// dedup to prevent double-handling.
|
|
1066
|
+
const onPasteRef = useRef(onPaste)
|
|
1067
|
+
onPasteRef.current = onPaste
|
|
1068
|
+
const pasteHandledRef = useRef(false)
|
|
1069
|
+
|
|
1070
|
+
// Always listen for paste events regardless of terminal focus state.
|
|
1071
|
+
// Drag-and-drop inherently causes the terminal to lose focus (the file
|
|
1072
|
+
// manager has focus during the drag), so the paste listener must stay
|
|
1073
|
+
// active even when `focused` is false.
|
|
1074
|
+
useEffect(() => {
|
|
1075
|
+
if (!keyHandler) return
|
|
1076
|
+
|
|
1077
|
+
const handlePaste = (event: PasteEvent) => {
|
|
1078
|
+
pasteHandledRef.current = true
|
|
1079
|
+
onPasteRef.current(getPasteText(event))
|
|
1080
|
+
// Reset dedup flag after microtask so scrollbox handler (which fires
|
|
1081
|
+
// synchronously after global listeners) sees it as handled, but future
|
|
1082
|
+
// paste events are not blocked.
|
|
1083
|
+
queueMicrotask(() => { pasteHandledRef.current = false })
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
keyHandler.on('paste', handlePaste)
|
|
1087
|
+
return () => {
|
|
1088
|
+
keyHandler.off('paste', handlePaste)
|
|
1089
|
+
}
|
|
1090
|
+
}, [keyHandler])
|
|
1091
|
+
|
|
1092
|
+
// Main keyboard handler - delegates to specialized handlers
|
|
1093
|
+
useKeyboard(
|
|
1094
|
+
useCallback(
|
|
1095
|
+
(key: KeyEvent) => {
|
|
1096
|
+
if (!focused) return
|
|
1097
|
+
|
|
1098
|
+
if (onKeyIntercept) {
|
|
1099
|
+
const handled = onKeyIntercept(key)
|
|
1100
|
+
if (handled) return
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Clear sticky column for non-vertical navigation
|
|
1104
|
+
const isVerticalNavKey = key.name === 'up' || key.name === 'down'
|
|
1105
|
+
if (!isVerticalNavKey) {
|
|
1106
|
+
stickyColumnRef.current = null
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Delegate to specialized handlers
|
|
1110
|
+
if (handleEnterKeys(key)) return
|
|
1111
|
+
if (handleDeletionKeys(key)) return
|
|
1112
|
+
if (handleNavigationKeys(key)) return
|
|
1113
|
+
if (handleCharacterInput(key)) return
|
|
1114
|
+
},
|
|
1115
|
+
[
|
|
1116
|
+
focused,
|
|
1117
|
+
onKeyIntercept,
|
|
1118
|
+
handleEnterKeys,
|
|
1119
|
+
handleDeletionKeys,
|
|
1120
|
+
handleNavigationKeys,
|
|
1121
|
+
handleCharacterInput,
|
|
1122
|
+
],
|
|
1123
|
+
),
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
const layoutMetrics = (() => {
|
|
1127
|
+
const safeMaxHeight = Math.max(1, maxHeight)
|
|
1128
|
+
const effectiveMinHeight = Math.max(1, Math.min(minHeight, safeMaxHeight))
|
|
1129
|
+
|
|
1130
|
+
const totalLines =
|
|
1131
|
+
lineInfo === null ? 0 : lineInfo.lineStartCols.length
|
|
1132
|
+
|
|
1133
|
+
// Add bottom gutter when cursor is on line 2 of exactly 2 lines
|
|
1134
|
+
const gutterEnabled =
|
|
1135
|
+
totalLines === 2 && cursorRow === 1 && totalLines + 1 <= safeMaxHeight
|
|
1136
|
+
|
|
1137
|
+
const rawHeight = Math.min(
|
|
1138
|
+
totalLines + (gutterEnabled ? 1 : 0),
|
|
1139
|
+
safeMaxHeight,
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
const heightLines = Math.max(effectiveMinHeight, rawHeight)
|
|
1143
|
+
|
|
1144
|
+
// Content is scrollable when total lines exceed max height
|
|
1145
|
+
const isScrollable = totalLines > safeMaxHeight
|
|
1146
|
+
|
|
1147
|
+
return {
|
|
1148
|
+
heightLines,
|
|
1149
|
+
gutterEnabled,
|
|
1150
|
+
isScrollable,
|
|
1151
|
+
}
|
|
1152
|
+
})()
|
|
1153
|
+
|
|
1154
|
+
const inputColor = isPlaceholder
|
|
1155
|
+
? theme.muted
|
|
1156
|
+
: focused
|
|
1157
|
+
? theme.inputFocusedFg
|
|
1158
|
+
: theme.inputFg
|
|
1159
|
+
|
|
1160
|
+
// Use theme's info color for selection highlight background
|
|
1161
|
+
const highlightBg = theme.info
|
|
1162
|
+
|
|
1163
|
+
return (
|
|
1164
|
+
<scrollbox
|
|
1165
|
+
ref={scrollBoxRef}
|
|
1166
|
+
scrollX={false}
|
|
1167
|
+
stickyScroll={true}
|
|
1168
|
+
stickyStart="bottom"
|
|
1169
|
+
scrollbarOptions={{ visible: false }}
|
|
1170
|
+
verticalScrollbarOptions={{
|
|
1171
|
+
visible: showScrollbar && layoutMetrics.isScrollable,
|
|
1172
|
+
trackOptions: { width: 1 },
|
|
1173
|
+
}}
|
|
1174
|
+
onPaste={(event) => {
|
|
1175
|
+
// Backup paste handler: fires if the global keyHandler listener
|
|
1176
|
+
// didn't catch this event (dedup prevents double-handling)
|
|
1177
|
+
if (pasteHandledRef.current) return
|
|
1178
|
+
onPasteRef.current(getPasteText(event))
|
|
1179
|
+
}}
|
|
1180
|
+
onMouseDown={handleMouseDown}
|
|
1181
|
+
style={{
|
|
1182
|
+
flexGrow: 0,
|
|
1183
|
+
flexShrink: 0,
|
|
1184
|
+
rootOptions: {
|
|
1185
|
+
width: '100%',
|
|
1186
|
+
height: layoutMetrics.heightLines,
|
|
1187
|
+
backgroundColor: 'transparent',
|
|
1188
|
+
flexGrow: 0,
|
|
1189
|
+
flexShrink: 0,
|
|
1190
|
+
},
|
|
1191
|
+
wrapperOptions: {
|
|
1192
|
+
paddingLeft: 1,
|
|
1193
|
+
paddingRight: 1,
|
|
1194
|
+
border: false,
|
|
1195
|
+
},
|
|
1196
|
+
contentOptions: {
|
|
1197
|
+
justifyContent: 'flex-start',
|
|
1198
|
+
},
|
|
1199
|
+
}}
|
|
1200
|
+
>
|
|
1201
|
+
<text
|
|
1202
|
+
ref={textRef}
|
|
1203
|
+
style={{ bg: 'transparent', fg: inputColor, wrapMode: 'word' }}
|
|
1204
|
+
>
|
|
1205
|
+
{showCursor ? (
|
|
1206
|
+
<>
|
|
1207
|
+
{beforeCursor}
|
|
1208
|
+
{shouldHighlight ? (
|
|
1209
|
+
<span
|
|
1210
|
+
bg={highlightBg}
|
|
1211
|
+
fg={theme.background}
|
|
1212
|
+
attributes={TextAttributes.BOLD}
|
|
1213
|
+
>
|
|
1214
|
+
{activeChar === ' ' ? '\u00a0' : activeChar}
|
|
1215
|
+
</span>
|
|
1216
|
+
) : (
|
|
1217
|
+
<InputCursor
|
|
1218
|
+
visible={true}
|
|
1219
|
+
focused={focused}
|
|
1220
|
+
shouldBlink={effectiveShouldBlinkCursor}
|
|
1221
|
+
color={supportsTruecolor() ? theme.info : 'magenta'}
|
|
1222
|
+
key={lastActivity}
|
|
1223
|
+
/>
|
|
1224
|
+
)}
|
|
1225
|
+
{shouldHighlight
|
|
1226
|
+
? afterCursor.length > 0
|
|
1227
|
+
? afterCursor.slice(1)
|
|
1228
|
+
: ''
|
|
1229
|
+
: afterCursor}
|
|
1230
|
+
{layoutMetrics.gutterEnabled ? '\n' : ''}
|
|
1231
|
+
</>
|
|
1232
|
+
) : (
|
|
1233
|
+
<>
|
|
1234
|
+
{displayValueForRendering}
|
|
1235
|
+
{layoutMetrics.gutterEnabled ? '\n' : ''}
|
|
1236
|
+
</>
|
|
1237
|
+
)}
|
|
1238
|
+
</text>
|
|
1239
|
+
</scrollbox>
|
|
1240
|
+
)
|
|
1241
|
+
})
|