diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ffde6919..3a3c5e70 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,6 +12,29 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Ghostty / libghostty-vt + +Aiden's in-app terminal uses a WebAssembly build of Ghostty's `libghostty-vt` +(`renderer/lib/ghostty-terminal/vendor/ghostty-vt.wasm`) plus a 112-byte PTY +callback trampoline. The browser host is adapted from T3 Code's MIT-licensed +`libghostty-vt` adapter. + +Ghostty: Copyright (c) 2024-2026 Mitchell Hashimoto and Ghostty contributors +T3 Code adapter: Copyright (c) 2026 T3 Tools Inc. + +MIT License. See `renderer/lib/ghostty-terminal/GHOSTTY-LICENSE` and +https://github.com/pingdotgg/t3code/blob/main/LICENSE + +## Symbols Nerd Font Mono + +Vendored as `renderer/lib/ghostty-terminal/fonts/SymbolsNerdFontMono-Regular.woff2` +for terminal prompt glyphs. + +Copyright (c) 2014 Ryan L McIntyre + +MIT License. See `renderer/lib/ghostty-terminal/fonts/LICENSE` + + ## Chart.js Chart.js is vendored into `resources/generative-ui` for sandboxed Generative UI artifacts. diff --git a/docs/plans/README.md b/docs/plans/README.md index 10a017e1..cdfe7ec6 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -16,6 +16,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.84.4. | | [Generative UI Artifacts](generative-ui-artifacts-plan.md) | Active | Phases 0–6 shipped: chat-scoped `render_artifact`, strict sandboxed preview/export hosts, verified vendored Chart.js/Plotly/KaTeX, permission-aware `/visualize`, crash-recoverable authoritative storage/copies, descriptor-relative workspace reads, one-iframe handoff/expansion, visible failure states, and route-stable Responding/Visualizing activity. Three-agent PR review findings are remediated with focused regression coverage. | | [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | +| [Libghostty workspace terminal](libghostty-terminal-plan.md) | Implemented | The workspace drawer uses Ghostty's official `libghostty-vt` WASM (T3-style runtime, PTY trampoline, canvas surface); node-pty sessions are unchanged. Packaged Mac acceptance remains. | | [Logging and Diagnostics Upgrade](logging-and-diagnostics-upgrade-plan.md) | Implemented | Phases 0–7 are implemented: bounded typed desktop journals, main-owned renderer evidence, local support export/delete, native categorical parity, and CI/release gates. Signed/notarized `v0.35.0` passed packaged diagnostics acceptance; physical-device termination receipts remain. | | [Long-thread payload upgrades](long-thread-payload-upgrade-plan.md) | Partial | Investigation complete: T3’s O(N²) stdout store does not exist here. No-op `toolRunning` timeline republish is skipped; Remote gzip, stream-journal debounce, chat JSON/attachments, and transcript windowing remain planned. | | [Model Insights](model-insights-plan.md) | Partial | A dedicated benchmark-only OpenRouter key, manual fetch, exact source-aware offline cache, metric-selectable collision-free capability suggestions, progressive canvas-first Pad UX, axis provenance, attribution, and direct-AA retirement ship; device-local pace signals remain. | diff --git a/docs/plans/libghostty-terminal-plan.md b/docs/plans/libghostty-terminal-plan.md new file mode 100644 index 00000000..6df10ab8 --- /dev/null +++ b/docs/plans/libghostty-terminal-plan.md @@ -0,0 +1,17 @@ +# Libghostty workspace terminal + +Status: Implemented + +Replace the workspace drawer's xterm.js emulator with Ghostty's official +`libghostty-vt` WebAssembly C ABI, following T3 Code's browser adapter: +runtime + write-pty trampoline, core snapshots, canvas renderer, and surface +input (IME, selection, scrollback, mouse reporting). + +PTY spawn, `TERM=xterm-256color`, snapshot hydrate, and session limits stay in +`main/services/terminal.ts`. + +## Remaining + +- Packaged/physical drawer acceptance on a signed Mac build. +- Optional later native Metal embed if Ghostty publishes a stable headless + surface API. diff --git a/docs/plans/performance-stability-efficiency-plan.md b/docs/plans/performance-stability-efficiency-plan.md index f5467aaf..1ab6358b 100644 --- a/docs/plans/performance-stability-efficiency-plan.md +++ b/docs/plans/performance-stability-efficiency-plan.md @@ -56,7 +56,7 @@ The first implementation milestone should therefore be **data safety and bounded | P1 | Scheduled catch-up has per-task overlap protection but no global budget or battery/lock policy | `main/services/schedule-service-core.ts` | Multiple missed tasks can stampede after startup/resume | | P2 | The closed model picker queries every provider and rebuilds/sorts catalog structures during parent renders | `renderer/components/model-picker.tsx`, `renderer/lib/queries.ts` | Stream-frame work scales with provider/model count | | P2 | Startup waits for provider enumeration/auth state before the first React render | `renderer/main/index.tsx`, `main/services/provider-registry.ts` | A slow keychain/provider probe delays visible app chrome | -| P2 | Routes, settings, xterm, KaTeX, and full Highlight.js are in an eager startup graph | `renderer/main/router.tsx`, `renderer/main/settings-view.tsx`, `renderer/components/code-block.tsx` | Larger parse/compile/startup and update payload | +| P2 | Routes, settings, terminal, KaTeX, and full Highlight.js are in an eager startup graph | `renderer/main/router.tsx`, `renderer/main/settings-view.tsx`, `renderer/components/code-block.tsx` | Larger parse/compile/startup and update payload | | P2 | Terminal output and LLM deltas cross IPC at source cadence; terminal buffers repeatedly copy large strings | `main/services/terminal.ts`, `main/services/llm-client.ts` | Excess wakeups, IPC allocations, and resize churn | | P2 | Several realistic lists are unwindowed | files, review, chat/model palettes, transcript | Large workspaces and histories degrade nonlinearly | @@ -239,7 +239,7 @@ Exit gate: #### 4B. Render sooner and load optional code later - Render the lightweight shell before provider enumeration. Show a truthful provider-hydration state and enable composer selection only after alias/identity migration is authoritative. -- Dynamically import Settings/Profile/Scheduled routes, individual heavy Settings sections, terminal/xterm on first open, and other secondary panels. +- Dynamically import Settings/Profile/Scheduled routes, individual heavy Settings sections, terminal/libghostty on first open, and other secondary panels. - Keep ChatLayout, transcript shell, composer, selected-model trigger, and generation bridge in the initial graph. Optionally idle-preload the next likely surface after first input readiness. - Use package-content allowlists. Bundle pure-JS main dependencies when safe; externalize/unpack only native/runtime-required modules. - Disable packaged source maps or publish hidden maps outside the app artifact. Add a test that no `.map` is shipped. diff --git a/main-window.html b/main-window.html index e45304cf..5ebcadef 100644 --- a/main-window.html +++ b/main-window.html @@ -5,7 +5,7 @@ Aiden Agent diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index 2c8cd9dc..db59f5c0 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -473,7 +473,7 @@ test("persisted history seeds a reopened terminal buffer", async () => { const session = await service.create("workspace-1", "/tmp", owner.owner); // The restored history is available via snapshot, so the renderer can - // re-hydrate xterm with the prior session's output. + // re-hydrate the Ghostty surface with the prior session's output. const snapshot = service.snapshot(session.id, owner.owner); assert.equal(snapshot.buffer, "prior output\n"); await service.flushHistory(); diff --git a/main/services/terminal.ts b/main/services/terminal.ts index f92ba101..f4c2fd94 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -298,7 +298,7 @@ export class TerminalService { throw new Error("The workspace changed before the terminal could start."); } // Restore the sanitized prior-session output so the terminal reopens with - // its history. The renderer writes this buffer to xterm on hydrate, so no + // its history. The renderer writes this buffer to the Ghostty surface on hydrate, so no // renderer change is required for the seed. const restoredHistory = await this.historyStore?.read(workspaceId); if (ownerInvalidated()) { diff --git a/package-lock.json b/package-lock.json index c20b7dad..773ed92e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,6 @@ "@radix-ui/colors": "^3.0.0", "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.36", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -5317,21 +5315,6 @@ "node": ">=10.0.0" } }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" - }, - "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", diff --git a/package.json b/package.json index 35cb8e8d..919a3113 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "type": "module", "main": "build/main/index.js", "scripts": { - "build": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && vite build && node scripts/verify-gemini-live-worklet.mjs && npm run build:electron", + "build": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && vite build && node scripts/verify-gemini-live-worklet.mjs && node scripts/verify-ghostty-terminal-assets.mjs && npm run build:electron", "computer-use:vendor": "node scripts/vendor-cua-driver.mjs", "build:native": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && node scripts/build-foundation-models-helper.mjs --required", "build:native:optional": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && node scripts/build-foundation-models-helper.mjs --optional", @@ -46,8 +46,8 @@ "generative-ui:vendor": "node scripts/vendor-generative-ui-libs.mjs", "pretest:generative-ui": "npm run build:subagent-file-mutator", "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", - "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", - "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", + "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:ghostty-terminal && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", + "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:ghostty-terminal && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/inline-metadata-hierarchy.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:sidebar": "tsx --test renderer/components/chat-sidebar.test.tsx renderer/lib/sidebar-workspace-groups.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts", "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", @@ -95,6 +95,7 @@ "test:e2e:list": "playwright test --config=playwright.config.ts --list", "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts", "test:terminal:coverage": "tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal-spawn-helper.ts --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 main/services/terminal.test.ts && tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal.ts --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-functions=90 main/services/terminal.test.ts", + "test:ghostty-terminal": "tsx --test renderer/lib/ghostty-terminal/core.test.ts renderer/lib/ghostty-terminal/keyCodes.test.ts renderer/lib/ghostty-terminal/runtime.test.ts renderer/components/terminal-drawer.test.tsx", "test:provider-failure": "tsx --test main/services/provider-failure.test.ts", "test:web-search": "tsx --test main/services/web-search.test.ts main/services/web-search-core.test.ts main/services/web-search-exa-core.test.ts main/services/web-search-provider-registry-core.test.ts main/services/web-search-provider-registry.test.ts main/services/web-search-credential-core.test.ts main/services/web-search-auth-reuse.test.ts main/services/web-search-rollout.test.ts main/services/web-search-wave1-adapters.test.ts main/services/web-search-wave1-ai.test.ts main/services/web-search-wave2-batch-a.test.ts main/services/web-search-wave2-batch-b.test.ts main/services/web-search-wave4-batch-a.test.ts main/services/web-search-wave4-batch-b.test.ts main/handlers/web-search-contract.test.ts renderer/components/settings/web-search-settings.test.tsx renderer/lib/settings-section.test.ts", "test:concentrate": "tsx --test main/services/concentrate-provider.test.ts", @@ -109,8 +110,8 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native", - "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts renderer/lib/ghostty-terminal/core.test.ts renderer/lib/ghostty-terminal/keyCodes.test.ts renderer/lib/ghostty-terminal/runtime.test.ts renderer/components/terminal-drawer.test.tsx main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native", + "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts renderer/lib/ghostty-terminal/core.test.ts renderer/lib/ghostty-terminal/keyCodes.test.ts renderer/lib/ghostty-terminal/runtime.test.ts renderer/components/terminal-drawer.test.tsx main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", @@ -140,8 +141,6 @@ "@radix-ui/colors": "^3.0.0", "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.36", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/renderer/components/terminal-drawer.test.tsx b/renderer/components/terminal-drawer.test.tsx new file mode 100644 index 00000000..3e02a8cd --- /dev/null +++ b/renderer/components/terminal-drawer.test.tsx @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +function source(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +test("the workspace terminal hosts libghostty-vt instead of xterm.js", () => { + const drawer = source("./terminal-drawer.tsx"); + const styles = source("../styles.css"); + const csp = source("../../main-window.html"); + const commands = source("../lib/command-system.tsx"); + const packageJson = source("../../package.json"); + const runtime = source("../lib/ghostty-terminal/runtime.ts"); + const wasmAssets = source("../lib/ghostty-terminal/wasm-assets.ts"); + + assert.match(drawer, /GhosttyTerminalSurface\.create/u); + assert.match(source("../lib/ghostty-terminal/surface.ts"), /from "\.\/wasm-assets"|import "\.\/wasm-assets"/u); + assert.match(drawer, /data-command-scope="terminal"/u); + assert.doesNotMatch(drawer, /@xterm\/xterm/u); + assert.doesNotMatch(drawer, /from "@xterm\/addon-fit"/u); + assert.match(styles, /\.ghostty-screen/u); + assert.doesNotMatch(styles, /\.xterm-viewport/u); + assert.match(csp, /wasm-unsafe-eval/u); + assert.match(commands, /\.ghostty-screen/u); + assert.match(runtime, /import\("\.\/wasm-assets"\)/u); + assert.match(runtime, /`\.\/vendor\/\$\{filename\}`/u); + assert.doesNotMatch(runtime, /new URL\("\.\/vendor\/ghostty-vt\.wasm"/u); + assert.doesNotMatch(runtime, /new URL\("\.", import\.meta\.url\)/u); + assert.match(wasmAssets, /new URL\("\.\/vendor\/ghostty-vt\.wasm", import\.meta\.url\)/u); + assert.match(wasmAssets, /new URL\("\.\/vendor\/ghostty-write-pty\.wasm", import\.meta\.url\)/u); + assert.match(source("../../vite.config.ts"), /assetsInlineLimit/u); + assert.doesNotMatch(packageJson, /"@xterm\/xterm"/u); + assert.doesNotMatch(packageJson, /"@xterm\/addon-fit"/u); +}); diff --git a/renderer/components/terminal-drawer.tsx b/renderer/components/terminal-drawer.tsx index 02db5bc6..97bb04c4 100644 --- a/renderer/components/terminal-drawer.tsx +++ b/renderer/components/terminal-drawer.tsx @@ -3,9 +3,8 @@ // resized, or hidden without leaving the chat. import * as React from "react"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal as Xterm } from "@xterm/xterm"; -import "@xterm/xterm/css/xterm.css"; +import type { GhosttyTerminalSurface } from "../lib/ghostty-terminal/surface"; +import { ghosttyThemeFromCss } from "../lib/ghostty-terminal/theme"; import { Minus, PanelBottomClose, @@ -309,7 +308,7 @@ function TerminalViewport({ clearEpoch: number; }) { const hostRef = React.useRef(null); - const xtermRef = React.useRef(null); + const surfaceRef = React.useRef(null); const resizeTerminalRef = React.useRef<(() => void) | null>(null); const onUnavailableRef = React.useRef(onUnavailable); onUnavailableRef.current = onUnavailable; @@ -317,92 +316,112 @@ function TerminalViewport({ React.useEffect(() => { const host = hostRef.current; if (!host) return; - const xterm = new Xterm({ - cursorBlink: true, - fontFamily: terminalFontFamily(), - fontSize: terminalFontSize(), - lineHeight: 1.25, - scrollback: 5_000, - theme: terminalTheme(), - }); - const fit = new FitAddon(); - xterm.loadAddon(fit); - xterm.open(host); - xtermRef.current = xterm; - const resize = () => { - try { - fit.fit(); - void terminalApi.resize(session.id, xterm.cols, xterm.rows).catch(() => undefined); - } catch { - // The host can briefly have zero size during drawer animation. - } - }; - resizeTerminalRef.current = resize; - const observer = new ResizeObserver(() => requestAnimationFrame(resize)); - observer.observe(host); - let hydrated = false; - let lastSequence = 0; - const queuedData: Array<{ sequence: number; data: string }> = []; - const writeData = (event: { sequence: number; data: string }) => { - if (event.sequence <= lastSequence) return; - lastSequence = event.sequence; - xterm.write(event.data); + let cancelled = false; + let surface: GhosttyTerminalSurface | null = null; + let cancelData: (() => void) | undefined; + const teardown = () => { + cancelData?.(); + cancelData = undefined; + if (surfaceRef.current === surface) surfaceRef.current = null; + surface?.dispose(); + surface = null; + resizeTerminalRef.current = null; }; - const cancelData = onNotification<{ sessionId: string; sequence: number; data: string }>( - "terminal:data", - (event) => { - if (event.sessionId !== session.id) return; - if (!hydrated) queuedData.push(event); - else writeData(event); - }, - ); - void terminalApi - .snapshot(session.id) - .then(({ buffer, sequence }) => { - if (xtermRef.current !== xterm) return; - if (buffer) xterm.write(buffer); + void import("../lib/ghostty-terminal/surface").then(async ({ GhosttyTerminalSurface }) => { + if (cancelled || !hostRef.current) return; + const next = await GhosttyTerminalSurface.create(hostRef.current, { + theme: ghosttyThemeFromCss(terminalTheme()), + font: { family: terminalFontFamily(), size: terminalFontSize() }, + onData: (data) => { + void terminalApi.write(session.id, data).catch(() => undefined); + }, + onResize: (cols, rows) => { + void terminalApi.resize(session.id, cols, rows).catch(() => undefined); + }, + onSelectionChange: () => undefined, + beforeKey: (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "j") return false; + return true; + }, + onLinkActivate: () => undefined, + }); + const mount = hostRef.current; + if (cancelled || !mount) { + next.dispose(); + return; + } + surface = next; + surfaceRef.current = next; + const resize = () => { + try { + next.fit(); + } catch { + // The host can briefly have zero size during drawer animation. + } + }; + resizeTerminalRef.current = resize; + let hydrated = false; + let lastSequence = 0; + const queuedData: Array<{ sequence: number; data: string }> = []; + const writeData = (event: { sequence: number; data: string }) => { + if (event.sequence <= lastSequence) return; + lastSequence = event.sequence; + next.write(event.data); + }; + cancelData = onNotification<{ sessionId: string; sequence: number; data: string }>( + "terminal:data", + (event) => { + if (event.sessionId !== session.id) return; + if (!hydrated) queuedData.push(event); + else writeData(event); + }, + ); + if (cancelled) { + teardown(); + return; + } + try { + const { buffer, sequence } = await terminalApi.snapshot(session.id); + if (cancelled) { + teardown(); + return; + } + if (buffer) next.resetAndWrite(buffer); lastSequence = sequence; hydrated = true; for (const event of queuedData) writeData(event); resize(); - }) - .catch(() => { - // Electron's main process can restart during development. Remove the - // renderer-side tab instead of leaving an inert terminal pane behind. - onUnavailableRef.current(); - }); - const disposeInput = xterm.onData( - (data) => void terminalApi.write(session.id, data).catch(() => undefined), - ); - xterm.attachCustomKeyEventHandler((event) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "j") return false; - return true; + } catch { + teardown(); + if (!cancelled) onUnavailableRef.current(); + return; + } + if (cancelled) { + teardown(); + return; + } + requestAnimationFrame(resize); }); - requestAnimationFrame(resize); return () => { - cancelData(); - observer.disconnect(); - disposeInput.dispose(); - xterm.dispose(); - xtermRef.current = null; - resizeTerminalRef.current = null; + cancelled = true; + teardown(); }; }, [session.id]); React.useEffect(() => { - if (active) requestAnimationFrame(() => xtermRef.current?.focus()); + if (active) requestAnimationFrame(() => surfaceRef.current?.focus()); }, [active]); React.useEffect(() => { - if (clearEpoch > 0) xtermRef.current?.clear(); + if (clearEpoch > 0) surfaceRef.current?.write("\x1b[2J\x1b[H"); }, [clearEpoch]); React.useEffect(() => { const updateAppearance = () => { - if (!xtermRef.current) return; - xtermRef.current.options.theme = terminalTheme(); - xtermRef.current.options.fontFamily = terminalFontFamily(); - xtermRef.current.options.fontSize = terminalFontSize(); + const surface = surfaceRef.current; + if (!surface) return; + surface.setTheme(ghosttyThemeFromCss(terminalTheme())); + void surface.setFont({ family: terminalFontFamily(), size: terminalFontSize() }); requestAnimationFrame(() => resizeTerminalRef.current?.()); }; window.addEventListener(APPEARANCE_CHANGE_EVENT, updateAppearance); @@ -414,7 +433,7 @@ function TerminalViewport({ ref={hostRef} data-command-scope="terminal" onMouseDown={onFocus} - className="h-full min-h-0 w-full select-text p-2" + className="relative h-full min-h-0 w-full overflow-hidden select-text p-2" /> ); } diff --git a/renderer/lib/command-system.tsx b/renderer/lib/command-system.tsx index 947b3e24..556e3281 100644 --- a/renderer/lib/command-system.tsx +++ b/renderer/lib/command-system.tsx @@ -174,7 +174,7 @@ export function CommandSystemProvider({ const commandId = resolveCommandForKeyEvent(event, bindings, { editable: isEditable(target), fileEditor: Boolean(target?.closest("[data-command-scope='fileEditor']")), - terminal: Boolean(target?.closest("[data-command-scope='terminal'], .xterm")), + terminal: Boolean(target?.closest("[data-command-scope='terminal'], .ghostty-screen")), modal: applicationModal || Boolean(document.querySelector('[data-slot="dialog-content"][data-state="open"]')), diff --git a/renderer/lib/ghostty-terminal/GHOSTTY-LICENSE b/renderer/lib/ghostty-terminal/GHOSTTY-LICENSE new file mode 100644 index 00000000..0a07a66c --- /dev/null +++ b/renderer/lib/ghostty-terminal/GHOSTTY-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/renderer/lib/ghostty-terminal/README.md b/renderer/lib/ghostty-terminal/README.md new file mode 100644 index 00000000..4c32e909 --- /dev/null +++ b/renderer/lib/ghostty-terminal/README.md @@ -0,0 +1,20 @@ +# Ghostty web terminal + +Aiden's workspace drawer hosts the same official `libghostty-vt` C ABI T3 Code +uses in the browser. This is not an xterm.js compatibility layer. + +- `runtime.ts` owns the singleton WebAssembly instance, type layouts, and the + 112-byte `ghostty-write-pty.wasm` callback trampoline for terminal-generated + PTY replies. +- `core.ts` owns per-terminal Ghostty handles and translates the C ABI into + render snapshots. +- `renderer.ts` batches backgrounds and style runs into a Canvas 2D frame. +- `surface.ts` owns browser input, IME, selection, scrolling, sizing, links, + and cursor blinking (adapted from T3 Code, MIT). +- `fonts/` vendors the symbols-only Nerd Font (MIT) so prompt glyphs render + without a locally installed Nerd Font. +- `vendor/` holds the WASM artifacts. `VERSION` is the pinned Ghostty revision + embedded in `ghostty-vt.wasm`. + +Keep browser behavior here and PTY transport in `main/services/terminal.ts`. +Do not add React state to the render loop. diff --git a/renderer/lib/ghostty-terminal/VERSION b/renderer/lib/ghostty-terminal/VERSION new file mode 100644 index 00000000..aa5d5e74 --- /dev/null +++ b/renderer/lib/ghostty-terminal/VERSION @@ -0,0 +1 @@ +9f62873bf195e4d8a762d768a1405a5f2f7b1697 diff --git a/renderer/lib/ghostty-terminal/appearance-fonts.ts b/renderer/lib/ghostty-terminal/appearance-fonts.ts new file mode 100644 index 00000000..b7a27049 --- /dev/null +++ b/renderer/lib/ghostty-terminal/appearance-fonts.ts @@ -0,0 +1,53 @@ +const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; +const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; +const MONOSPACE_ADVANCE_TOLERANCE = 0.01; + +let fontProbeContext: CanvasRenderingContext2D | null | undefined; + +function quoteFontFamilyName(name: string): string { + const bare = name.trim(); + if (bare.length === 0) return ""; + if (/^(['"]).*\1$/u.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/u.test(bare)) return bare; + return `"${bare.replace(/"/gu, "")}"`; +} + +export function cssFontFamilies(input: string): string | null { + const families = input + .split(",") + .map(quoteFontFamilyName) + .filter((name) => name.length > 0); + return families.length > 0 ? families.join(", ") : null; +} + +export function areFontAdvancesMonospace(advances: readonly number[]): boolean { + const reference = advances[0]; + if ( + reference === undefined || + reference <= 0 || + advances.some((advance) => !Number.isFinite(advance) || advance <= 0) + ) { + return true; + } + return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); +} + +export function isMonospaceFamily(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return true; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return true; + const context = fontProbeContext; + for (const variant of MONOSPACE_PROBE_VARIANTS) { + context.font = `${variant} 32px ${families}, monospace`; + const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); + if (!areFontAdvancesMonospace(advances)) return false; + } + return true; + } catch { + return true; + } +} diff --git a/renderer/lib/ghostty-terminal/core.test.ts b/renderer/lib/ghostty-terminal/core.test.ts new file mode 100644 index 00000000..efc0572d --- /dev/null +++ b/renderer/lib/ghostty-terminal/core.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ghosttyCellText } from "./core"; + +function codepointView(codepoints: ReadonlyArray): DataView { + const view = new DataView(new ArrayBuffer(codepoints.length * 4)); + codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true)); + return view; +} + +test("ghosttyCellText converts oversized grapheme clusters without spread limits", () => { + const graphemeLength = 130_000; + const view = new DataView(new ArrayBuffer(graphemeLength * 4)); + for (let index = 0; index < graphemeLength; index += 1) { + view.setUint32(index * 4, index === 0 ? "a".codePointAt(0)! : 0x301, true); + } + const text = ghosttyCellText(view, graphemeLength); + assert.equal(text.length, graphemeLength); + assert.equal(text.codePointAt(0), "a".codePointAt(0)); + assert.equal(text.codePointAt(1), 0x301); + assert.equal(text.codePointAt(graphemeLength - 1), 0x301); +}); + +test("ghosttyCellText converts small clusters including astral codepoints", () => { + const text = ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2); + assert.deepEqual([...text], ["\u{1F642}", "\u{20E3}"]); +}); + +test("ghosttyCellText returns an empty string for empty cells", () => { + assert.equal(ghosttyCellText(codepointView([]), 0), ""); +}); diff --git a/renderer/lib/ghostty-terminal/core.ts b/renderer/lib/ghostty-terminal/core.ts new file mode 100644 index 00000000..14e7b9f5 --- /dev/null +++ b/renderer/lib/ghostty-terminal/core.ts @@ -0,0 +1,1229 @@ +import { + type GhosttyKeyboardLayoutMap, + ghosttyConsumedMods, + ghosttyKeyForCode, + ghosttyUnshiftedCodepoint, + loadGhosttyKeyboardLayoutMap, +} from "./keyCodes"; +import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; + +const GHOSTTY_SUCCESS = 0; +const GHOSTTY_OUT_OF_SPACE = -3; +const MAX_SCROLLBACK_ROWS = 10_000; +// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the +// libghostty-vt revision pinned alongside this module. +const SELECTION_FORMAT_OPTIONS_SIZE = 16; + +const RENDER_DATA = { + cols: 1, + rows: 2, + dirty: 3, + rowIterator: 4, + background: 5, + foreground: 6, + cursor: 7, + cursorHasValue: 8, + cursorStyle: 10, + cursorVisible: 11, + cursorBlinking: 12, + cursorInViewport: 14, + cursorX: 15, + cursorY: 16, +} as const; + +const ROW_DATA = { + dirty: 1, + raw: 2, + cells: 3, +} as const; + +const CELL_DATA = { + raw: 1, + style: 2, + graphemesLength: 3, + graphemes: 4, + background: 5, + foreground: 6, + selected: 7, +} as const; + +const RAW_CELL_DATA = { + wide: 3, +} as const; + +export const GHOSTTY_CELL_WIDE = { + narrow: 0, + wide: 1, + spacerTail: 2, + spacerHead: 3, +} as const; + +export interface GhosttyColor { + readonly r: number; + readonly g: number; + readonly b: number; +} + +export interface GhosttyTheme { + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ + readonly selectionBackground?: string; +} + +export interface GhosttyCell { + readonly text: string; + readonly wide: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly bold: boolean; + readonly italic: boolean; + readonly invisible: boolean; + readonly strikethrough: boolean; + readonly overline: boolean; + readonly underline: boolean; + readonly selected: boolean; +} + +export interface GhosttyRow { + readonly cells: readonly GhosttyCell[]; + readonly text: string; + readonly isWrapContinuation: boolean; + /** Whether this row soft-wraps onto the next row. */ + readonly wrapsToNext: boolean; +} + +export interface GhosttySnapshot { + readonly cols: number; + readonly rows: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + readonly cursorX: number; + readonly cursorY: number; + readonly cursorVisible: boolean; + readonly cursorBlinking: boolean; + readonly cursorStyle: number; + readonly dirtyRows: ReadonlySet; + readonly rowData: readonly GhosttyRow[]; +} + +export interface GhosttySelectionRange { + readonly viewport: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; + readonly screen: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; +} + +export interface GhosttyScrollbar { + readonly total: number; + readonly offset: number; + readonly len: number; +} + +/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */ +export interface GhosttyPointInput { + readonly x: number; + readonly y: number; + readonly tag?: 1 | 2; +} + +export interface GhosttyMouseInput { + readonly action: "press" | "release" | "motion"; + readonly button: number | null; + readonly mods: number; + readonly x: number; + readonly y: number; + readonly screenWidth: number; + readonly screenHeight: number; + readonly cellWidth: number; + readonly cellHeight: number; + readonly paddingLeft: number; + readonly paddingRight: number; + readonly paddingTop: number; + readonly paddingBottom: number; + readonly anyButtonPressed: boolean; +} + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor { + const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255); + return { + r: channel(foreground.r, background.r), + g: channel(foreground.g, background.g), + b: channel(foreground.b, background.b), + }; +} + +function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { + return left.r === right.r && left.g === right.g && left.b === right.b; +} + +/** + * A terminal program can print one base character followed by a huge run of + * combining marks, packing hundreds of thousands of codepoints into a single + * cell that still fits the scrollback buffer. Engines cap spread-call + * arguments far below that, so convert in bounded chunks instead of spreading + * every codepoint into String.fromCodePoint at once. + */ +export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { + const CHUNK_SIZE = 4_096; + let text = ""; + for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { + const count = Math.min(CHUNK_SIZE, graphemeLength - start); + const codes = new Array(count); + for (let index = 0; index < count; index += 1) { + codes[index] = codepointView.getUint32((start + index) * 4, true); + } + text += String.fromCodePoint(...codes); + } + return text; +} + +export class GhosttyTerminalCore { + private readonly runtime: GhosttyRuntime; + private terminalSlot = 0; + private terminal = 0; + private renderStateSlot = 0; + private renderState = 0; + private rowIteratorSlot = 0; + private rowCellsSlot = 0; + private keyEncoderSlot = 0; + private keyEncoder = 0; + private keyEventSlot = 0; + private keyEvent = 0; + private mouseEncoderSlot = 0; + private mouseEncoder = 0; + private mouseEventSlot = 0; + private mouseEvent = 0; + private ptyWriterId = 0; + private ptyWriter: ((data: string) => void) | null = null; + private scratch = 0; + private style = 0; + private scrollbar = 0; + private rows: GhosttyRow[] = []; + private disposed = false; + private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined; + + private constructor(runtime: GhosttyRuntime) { + this.runtime = runtime; + void loadGhosttyKeyboardLayoutMap().then((layoutMap) => { + if (!this.disposed) this.keyboardLayoutMap = layoutMap; + }); + } + + static async create( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): Promise { + const core = new GhosttyTerminalCore(await loadGhosttyRuntime()); + try { + core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData); + return core; + } catch (error) { + core.dispose(); + throw error; + } + } + + private initialize( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): void { + const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size; + const options = this.runtime.alloc(optionsSize); + this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); + this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); + this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); + this.terminalSlot = this.runtime.allocOpaque(); + const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); + this.runtime.free(options, optionsSize); + this.assertSuccess("ghostty_terminal_new", terminalResult); + this.terminal = this.runtime.readPointer(this.terminalSlot); + this.applyDefaultCursorBlink(); + this.ptyWriter = onPtyData; + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); + + this.renderStateSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_new", + this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot), + ); + this.renderState = this.runtime.readPointer(this.renderStateSlot); + + this.rowIteratorSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_row_iterator_new", + this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot), + ); + this.rowCellsSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_row_cells_new", + this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot), + ); + + this.keyEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_key_encoder_new", + this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot), + ); + this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot); + this.keyEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_key_event_new", + this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot), + ); + this.keyEvent = this.runtime.readPointer(this.keyEventSlot); + + this.mouseEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_mouse_encoder_new", + this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot), + ); + this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot); + this.mouseEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_mouse_event_new", + this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot), + ); + this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot); + + this.scratch = this.runtime.alloc(16); + const styleSize = this.runtime.layout("GhosttyStyle").size; + this.style = this.runtime.alloc(styleSize); + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); + this.setTheme(theme); + this.resize(cols, rows, cellWidth, cellHeight); + } + + write(data: string | Uint8Array): void { + this.ensureActive(); + const bytes = typeof data === "string" ? encoder.encode(data) : data; + if (bytes.length === 0) return; + const pointer = this.runtime.alloc(bytes.length); + this.runtime.bytes(pointer, bytes.length).set(bytes); + this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length); + this.runtime.free(pointer, bytes.length); + } + + resetAndWrite(data: string): void { + this.ensureActive(); + this.runtime.call("ghostty_terminal_reset", this.terminal); + // RIS returns the cursor to Ghostty's built-in steady default, so the + // embedder default has to be applied again before the replay runs. + this.applyDefaultCursorBlink(); + this.rows = []; + if (data.length === 0) return; + const writer = this.ptyWriter; + if (this.ptyWriterId !== 0) { + this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.ptyWriterId = 0; + } + try { + this.write(data); + } finally { + if (writer !== null && !this.disposed) { + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); + } + } + } + + resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void { + this.ensureActive(); + this.assertSuccess( + "ghostty_terminal_resize", + this.runtime.call( + "ghostty_terminal_resize", + this.terminal, + Math.max(1, Math.min(65_535, cols)), + Math.max(1, Math.min(65_535, rows)), + Math.max(1, Math.round(cellWidth)), + Math.max(1, Math.round(cellHeight)), + ), + ); + } + + /** + * Ghostty's built-in default cursor is steady, while the xterm.js renderer + * this replaced ran with `cursorBlink: true`. Option 23 is the embedder's + * default blink, which is the state a session starts in and returns to on + * DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor + * through DECSCUSR or DEC mode 12 still win. + */ + private applyDefaultCursorBlink(): void { + const blink = this.runtime.alloc(1); + this.runtime.bytes(blink, 1)[0] = 1; + this.runtime.call("ghostty_terminal_set", this.terminal, 23, blink); + this.runtime.free(blink, 1); + } + + setTheme(theme: GhosttyTheme): void { + this.ensureActive(); + const color = this.runtime.alloc(3); + for (const [option, value] of [ + [11, theme.foreground], + [12, theme.background], + [13, theme.cursor], + ] as const) { + this.runtime.bytes(color, 3).set([value.r, value.g, value.b]); + this.runtime.call("ghostty_terminal_set", this.terminal, option, color); + } + this.runtime.free(color, 3); + } + + scroll(deltaRows: number): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2); + const value = layout.fields.value!; + this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + scrollToBottom(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + isViewportActive(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + scrollbarState(): GhosttyScrollbar | null { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollbar"); + this.runtime.bytes(this.scrollbar, layout.size).fill(0); + if ( + this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !== + GHOSTTY_SUCCESS + ) { + return null; + } + return { + total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"), + offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"), + len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"), + }; + } + + isMouseTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isMouseAnyEventTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isAlternateScreen(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 4).fill(0); + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1 + ); + } + + isApplicationCursorKeys(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { + this.ensureActive(); + this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); + this.runtime.call( + "ghostty_key_event_set_action", + this.keyEvent, + action === "release" ? 0 : event.repeat ? 2 : 1, + ); + this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); + const mods = + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0) | + (event.getModifierState("CapsLock") ? 1 << 4 : 0) | + (event.getModifierState("NumLock") ? 1 << 5 : 0); + this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); + this.runtime.call( + "ghostty_key_event_set_consumed_mods", + this.keyEvent, + ghosttyConsumedMods(event), + ); + this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); + this.runtime.call( + "ghostty_key_event_set_unshifted_codepoint", + this.keyEvent, + ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap), + ); + + const text = event.key.length === 1 ? event.key : ""; + const textBytes = encoder.encode(text); + const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length); + if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes); + this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length); + + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + "ghostty_key_encoder_encode", + this.keyEncoder, + this.keyEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call("ghostty_wasm_free_usize", written); + if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length); + return encoded; + } + + encodePaste(data: string): string { + this.ensureActive(); + const input = encoder.encode(data); + if (input.length === 0) return ""; + const inputPointer = this.runtime.alloc(input.length); + this.runtime.bytes(inputPointer, input.length).set(input); + this.runtime.bytes(this.scratch, 1)[0] = 0; + const bracketed = + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0; + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + let encoded = ""; + const sizeResult = this.runtime.call( + "ghostty_paste_encode", + inputPointer, + input.length, + bracketed ? 1 : 0, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_paste_encode", + inputPointer, + input.length, + bracketed ? 1 : 0, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(inputPointer, input.length); + return encoded; + } + + encodeMouse(input: GhosttyMouseInput): string { + this.ensureActive(); + this.runtime.call( + "ghostty_mouse_encoder_setopt_from_terminal", + this.mouseEncoder, + this.terminal, + ); + + const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize"); + const size = this.runtime.alloc(sizeLayout.size); + for (const [field, value] of [ + ["size", sizeLayout.size], + ["screen_width", input.screenWidth], + ["screen_height", input.screenHeight], + ["cell_width", input.cellWidth], + ["cell_height", input.cellHeight], + ["padding_top", input.paddingTop], + ["padding_bottom", input.paddingBottom], + ["padding_right", input.paddingRight], + ["padding_left", input.paddingLeft], + ] as const) { + this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value))); + } + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size); + this.runtime.free(size, sizeLayout.size); + + this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0; + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch); + this.runtime.bytes(this.scratch, 1)[0] = 1; + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch); + + this.runtime.call( + "ghostty_mouse_event_set_action", + this.mouseEvent, + input.action === "press" ? 0 : input.action === "release" ? 1 : 2, + ); + if (input.button === null) { + this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent); + } else { + this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button); + } + this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods); + const positionLayout = this.runtime.layout("GhosttyMousePosition"); + const position = this.runtime.alloc(positionLayout.size); + const positionView = this.runtime.view(position, positionLayout.size); + positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true); + positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true); + this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position); + this.runtime.free(position, positionLayout.size); + + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + "ghostty_mouse_encoder_encode", + this.mouseEncoder, + this.mouseEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call("ghostty_wasm_free_usize", written); + return encoded; + } + + setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { + this.ensureActive(); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; + const selection = this.runtime.alloc(selectionLayout.size); + let start = 0; + let endRef = 0; + try { + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1); + endRef = this.gridRef(end.x, end.y, end.tag ?? 1); + const startField = selectionLayout.fields.start!; + const endField = selectionLayout.fields.end!; + this.runtime + .bytes(selection + startField.offset, startField.size) + .set(this.runtime.bytes(start, startField.size)); + this.runtime + .bytes(selection + endField.offset, endField.size) + .set(this.runtime.bytes(endRef, endField.size)); + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } finally { + this.runtime.free(start, gridRefSize); + this.runtime.free(endRef, gridRefSize); + this.runtime.free(selection, selectionLayout.size); + } + } + + selectAll(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttySelection"); + const selection = this.runtime.alloc(layout.size); + this.runtime.setField(selection, "GhosttySelection", "size", layout.size); + if ( + this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS + ) { + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + this.runtime.free(selection, layout.size); + } + + selectWord(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + "GhosttyTerminalSelectWordOptions", + "ghostty_terminal_select_word", + col, + row, + ); + } + + selectLine(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + "GhosttyTerminalSelectLineOptions", + "ghostty_terminal_select_line", + col, + row, + ); + } + + hyperlinkAt(col: number, row: number): string | null { + this.ensureActive(); + const ref = this.gridRef(col, row); + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let hyperlink: string | null = null; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_grid_ref_hyperlink_uri", + ref, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS && outputLength > 0) { + hyperlink = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + return hyperlink; + } + + clearSelection(): void { + this.ensureActive(); + this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0); + } + + snapshot(): GhosttySnapshot { + this.ensureActive(); + this.assertSuccess( + "ghostty_render_state_update", + this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal), + ); + const cols = this.getU16(RENDER_DATA.cols); + const rowCount = this.getU16(RENDER_DATA.rows); + const dirty = this.getU32(RENDER_DATA.dirty); + const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 }); + const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 }); + const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue); + const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground; + const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport); + const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport; + const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1; + const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1; + + if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) { + this.rows = Array.from({ length: rowCount }, () => ({ + cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)), + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })); + } + + const dirtyRows = new Set(); + if (dirty !== 0) { + this.assertSuccess( + "ghostty_render_state_get(row iterator)", + this.runtime.call( + "ghostty_render_state_get", + this.renderState, + RENDER_DATA.rowIterator, + this.rowIteratorSlot, + ), + ); + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + let rowIndex = 0; + while ( + rowIndex < rowCount && + this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0 + ) { + const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty); + if (rowDirty) { + this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); + dirtyRows.add(rowIndex); + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); + } + rowIndex += 1; + } + this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); + } + + return { + cols, + rows: rowCount, + foreground, + background, + cursor, + cursorX, + cursorY, + cursorVisible, + cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking), + cursorStyle: this.getU32(RENDER_DATA.cursorStyle), + dirtyRows, + rowData: this.rows, + }; + } + + selectionText(): string { + this.ensureActive(); + const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE); + const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE); + optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true); + optionsView.setUint32(4, 0, true); + optionsView.setUint8(8, 1); + optionsView.setUint8(9, 1); + optionsView.setUint32(12, 0, true); + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const sizeResult = this.runtime.call( + "ghostty_terminal_selection_format_buf", + this.terminal, + options, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let text = ""; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_terminal_selection_format_buf", + this.terminal, + options, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS) { + text = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE); + return text; + } + + viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 1, 2); + } + + screenPointToViewport(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 2, 1); + } + + private convertPoint( + col: number, + row: number, + fromTag: 1 | 2, + toTag: 1 | 2, + ): { x: number; y: number } | null { + this.ensureActive(); + const ref = this.gridRef(col, row, fromTag); + const point = this.pointFromGridRef(ref, toTag); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + return point; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent); + if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder); + if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent); + if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder); + if (this.rowCellsSlot) { + const cells = this.runtime.readPointer(this.rowCellsSlot); + if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells); + } + if (this.rowIteratorSlot) { + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator); + } + if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState); + if (this.terminal) { + if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.runtime.call("ghostty_terminal_free", this.terminal); + } + if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size); + if (this.scrollbar) { + this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); + } + if (this.scratch) this.runtime.free(this.scratch, 16); + for (const slot of [ + this.mouseEventSlot, + this.mouseEncoderSlot, + this.keyEventSlot, + this.keyEncoderSlot, + this.rowCellsSlot, + this.rowIteratorSlot, + this.renderStateSlot, + this.terminalSlot, + ]) { + this.runtime.freeOpaque(slot); + } + } + + private encodeOutput( + written: number, + encode: (output: number, outputSize: number) => number, + ): string { + const sizeResult = encode(0, 0); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return ""; + if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return ""; + + const output = this.runtime.alloc(outputSize); + const result = encode(output, outputSize); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + const encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; + this.runtime.free(output, outputSize); + return encoded; + } + + private readRow( + iterator: number, + cols: number, + defaultForeground: GhosttyColor, + defaultBackground: GhosttyColor, + ): GhosttyRow { + this.assertSuccess( + "ghostty_render_state_row_get(raw)", + this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), + ); + const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + "ghostty_row_get(wrap continuation)", + this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), + ); + const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + "ghostty_row_get(wrap)", + this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), + ); + const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + + this.assertSuccess( + "ghostty_render_state_row_get(cells)", + this.runtime.call( + "ghostty_render_state_row_get", + iterator, + ROW_DATA.cells, + this.rowCellsSlot, + ), + ); + const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const cells: GhosttyCell[] = []; + while ( + cells.length < cols && + this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 + ) { + let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); + let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); + const styleSize = this.runtime.layout("GhosttyStyle").size; + this.runtime.bytes(this.style, styleSize).fill(0); + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.style, + this.style, + ); + const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; + if (inverse) [foreground, background] = [background, foreground]; + if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { + foreground = blend(foreground, background); + } + const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); + let text = ""; + if (graphemeLength > 0) { + const bufferSize = graphemeLength * 4; + const codepoints = this.runtime.alloc(bufferSize); + if ( + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.graphemes, + codepoints, + ) === GHOSTTY_SUCCESS + ) { + // Read through a DataView: the byte-array allocator guarantees no + // 4-byte alignment, which a Uint32Array view would require. + const codepointView = this.runtime.view(codepoints, bufferSize); + text = ghosttyCellText(codepointView, graphemeLength); + } + this.runtime.free(codepoints, bufferSize); + } + let wide = 0; + if (text.length === 0 && cells[cells.length - 1]?.text.length) { + this.assertSuccess( + "ghostty_render_state_row_cells_get(raw)", + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.raw, + this.scratch, + ), + ); + const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + this.assertSuccess( + "ghostty_cell_get(wide)", + this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), + ); + wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + } + cells.push({ + text, + wide, + foreground, + background, + bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, + italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, + invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, + strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, + overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, + underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, + selected: this.getCellBool(cellsIterator, CELL_DATA.selected), + }); + } + while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); + return { + cells, + text: cells + .map((cell) => cell.text || " ") + .join("") + .trimEnd(), + isWrapContinuation, + wrapsToNext, + }; + } + + private gridRef(col: number, row: number, tag: 1 | 2 = 1): number { + const pointLayout = this.runtime.layout("GhosttyPoint"); + const point = this.runtime.alloc(pointLayout.size); + this.runtime.setField(point, "GhosttyPoint", "tag", tag); + const pointValue = pointLayout.fields.value!; + const valueOffset = pointValue.offset; + const view = this.runtime.view(point + valueOffset, pointValue.size); + view.setUint16(0, Math.max(0, col), true); + view.setUint32(4, Math.max(0, row), true); + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; + const gridRef = this.runtime.alloc(gridRefSize); + this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize); + const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef); + this.runtime.free(point, pointLayout.size); + if (result !== GHOSTTY_SUCCESS) { + this.runtime.free(gridRef, gridRefSize); + this.assertSuccess("ghostty_terminal_grid_ref", result); + } + return gridRef; + } + + private selectAt( + optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions", + operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line", + col: number, + row: number, + ): GhosttySelectionRange | null { + this.ensureActive(); + const optionsLayout = this.runtime.layout(optionsName); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const options = this.runtime.alloc(optionsLayout.size); + let ref = 0; + let selection = 0; + let range: GhosttySelectionRange | null = null; + try { + this.runtime.setField(options, optionsName, "size", optionsLayout.size); + ref = this.gridRef(col, row); + const refField = optionsLayout.fields.ref!; + this.runtime + .bytes(options + refField.offset, refField.size) + .set(this.runtime.bytes(ref, refField.size)); + selection = this.runtime.alloc(selectionLayout.size); + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + const result = this.runtime.call(operation, this.terminal, options, selection); + if (result === GHOSTTY_SUCCESS) { + const start = selection + selectionLayout.fields.start!.offset; + const end = selection + selectionLayout.fields.end!.offset; + const viewportStart = this.pointFromGridRef(start, 1); + const viewportEnd = this.pointFromGridRef(end, 1); + const screenStart = this.pointFromGridRef(start, 2); + const screenEnd = this.pointFromGridRef(end, 2); + if (viewportStart && viewportEnd && screenStart && screenEnd) { + range = { + viewport: { start: viewportStart, end: viewportEnd }, + screen: { start: screenStart, end: screenEnd }, + }; + } + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + } finally { + this.runtime.free(selection, selectionLayout.size); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + this.runtime.free(options, optionsLayout.size); + } + return range; + } + + private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { + const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate"); + const coordinate = this.runtime.alloc(coordinateLayout.size); + const result = this.runtime.call( + "ghostty_terminal_point_from_grid_ref", + this.terminal, + ref, + tag, + coordinate, + ); + const point = + result === GHOSTTY_SUCCESS + ? { + x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), + y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), + } + : null; + this.runtime.free(coordinate, coordinateLayout.size); + return point; + } + + private getU16(data: number): number { + this.runtime.bytes(this.scratch, 2).fill(0); + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 2).getUint16(0, true); + } + + private getU32(data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 4).getUint32(0, true); + } + + private getBool(data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.bytes(this.scratch, 1)[0] !== 0; + } + + private getColor(data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + "ghostty_render_state_get", + this.renderState, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private getRowBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellU32(iterator: number, data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + const result = this.runtime.call( + "ghostty_render_state_row_cells_get", + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; + } + + private getCellBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + "ghostty_render_state_row_cells_get", + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private readColor(pointer: number): GhosttyColor { + const bytes = this.runtime.bytes(pointer, 3); + return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; + } + + private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { + return { + text: "", + wide: 0, + foreground, + background, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, + }; + } + + private assertSuccess(operation: string, result: number): void { + if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`); + } + + private ensureActive(): void { + if (this.disposed) throw new Error("libghostty-vt terminal has been disposed"); + } +} + +export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean { + return sameColor(left, right); +} diff --git a/renderer/lib/ghostty-terminal/fonts/LICENSE b/renderer/lib/ghostty-terminal/fonts/LICENSE new file mode 100644 index 00000000..06eb073d --- /dev/null +++ b/renderer/lib/ghostty-terminal/fonts/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/renderer/lib/ghostty-terminal/fonts/SymbolsNerdFontMono-Regular.woff2 b/renderer/lib/ghostty-terminal/fonts/SymbolsNerdFontMono-Regular.woff2 new file mode 100644 index 00000000..1e2127fa Binary files /dev/null and b/renderer/lib/ghostty-terminal/fonts/SymbolsNerdFontMono-Regular.woff2 differ diff --git a/renderer/lib/ghostty-terminal/host-platform.ts b/renderer/lib/ghostty-terminal/host-platform.ts new file mode 100644 index 00000000..ff30d895 --- /dev/null +++ b/renderer/lib/ghostty-terminal/host-platform.ts @@ -0,0 +1,4 @@ +export function isMacPlatform(platform?: string): boolean { + const value = platform ?? (typeof navigator === "undefined" ? "" : navigator.platform); + return /Mac|iPhone|iPad|iPod/iu.test(value); +} diff --git a/renderer/lib/ghostty-terminal/keyCodes.test.ts b/renderer/lib/ghostty-terminal/keyCodes.test.ts new file mode 100644 index 00000000..870edb2e --- /dev/null +++ b/renderer/lib/ghostty-terminal/keyCodes.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; + +test("ghosttyKeyForCode keeps the tail of the pinned Ghostty key enum in order", () => { + assert.equal(ghosttyKeyForCode("F25"), ghosttyKeyForCode("F24") + 1); + assert.equal(ghosttyKeyForCode("PrintScreen"), ghosttyKeyForCode("FnLock") + 1); + assert.equal(ghosttyKeyForCode("Pause"), ghosttyKeyForCode("ScrollLock") + 1); + assert.equal(ghosttyKeyForCode("Paste"), ghosttyKeyForCode("Cut") + 1); +}); + +test("ghosttyConsumedMods only consumes a lone Shift producing a character", () => { + const shifted = { altKey: false, ctrlKey: false, key: "@", metaKey: false, shiftKey: true }; + assert.equal(ghosttyConsumedMods(shifted), 1); + assert.equal(ghosttyConsumedMods({ ...shifted, ctrlKey: true }), 0); + assert.equal(ghosttyConsumedMods({ ...shifted, key: "Tab" }), 0); + assert.equal(ghosttyConsumedMods({ ...shifted, key: " " }), 1); +}); + +test("ghosttyUnshiftedCodepoint provides the logical base character", () => { + assert.equal( + ghosttyUnshiftedCodepoint({ code: "KeyC", key: "c", shiftKey: false }), + "c".codePointAt(0), + ); + assert.equal( + ghosttyUnshiftedCodepoint({ code: "KeyC", key: "C", shiftKey: true }), + "c".codePointAt(0), + ); + assert.equal( + ghosttyUnshiftedCodepoint({ code: "Digit1", key: "!", shiftKey: true }), + "1".codePointAt(0), + ); + assert.equal(ghosttyUnshiftedCodepoint({ code: "Enter", key: "Enter", shiftKey: false }), 0); +}); diff --git a/renderer/lib/ghostty-terminal/keyCodes.ts b/renderer/lib/ghostty-terminal/keyCodes.ts new file mode 100644 index 00000000..a643827d --- /dev/null +++ b/renderer/lib/ghostty-terminal/keyCodes.ts @@ -0,0 +1,267 @@ +// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are +// intentionally derived from the official W3C-aligned enum instead of +// maintaining a second keyboard protocol. +const ghosttyKeyboardCodes = [ + "Unidentified", + "Backquote", + "Backslash", + "BracketLeft", + "BracketRight", + "Comma", + "Digit0", + "Digit1", + "Digit2", + "Digit3", + "Digit4", + "Digit5", + "Digit6", + "Digit7", + "Digit8", + "Digit9", + "Equal", + "IntlBackslash", + "IntlRo", + "IntlYen", + "KeyA", + "KeyB", + "KeyC", + "KeyD", + "KeyE", + "KeyF", + "KeyG", + "KeyH", + "KeyI", + "KeyJ", + "KeyK", + "KeyL", + "KeyM", + "KeyN", + "KeyO", + "KeyP", + "KeyQ", + "KeyR", + "KeyS", + "KeyT", + "KeyU", + "KeyV", + "KeyW", + "KeyX", + "KeyY", + "KeyZ", + "Minus", + "Period", + "Quote", + "Semicolon", + "Slash", + "AltLeft", + "AltRight", + "Backspace", + "CapsLock", + "ContextMenu", + "ControlLeft", + "ControlRight", + "Enter", + "MetaLeft", + "MetaRight", + "ShiftLeft", + "ShiftRight", + "Space", + "Tab", + "Convert", + "KanaMode", + "NonConvert", + "Delete", + "End", + "Help", + "Home", + "Insert", + "PageDown", + "PageUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "NumLock", + "Numpad0", + "Numpad1", + "Numpad2", + "Numpad3", + "Numpad4", + "Numpad5", + "Numpad6", + "Numpad7", + "Numpad8", + "Numpad9", + "NumpadAdd", + "NumpadBackspace", + "NumpadClear", + "NumpadClearEntry", + "NumpadComma", + "NumpadDecimal", + "NumpadDivide", + "NumpadEnter", + "NumpadEqual", + "NumpadMemoryAdd", + "NumpadMemoryClear", + "NumpadMemoryRecall", + "NumpadMemoryStore", + "NumpadMemorySubtract", + "NumpadMultiply", + "NumpadParenLeft", + "NumpadParenRight", + "NumpadSubtract", + "NumpadSeparator", + "NumpadArrowUp", + "NumpadArrowDown", + "NumpadArrowRight", + "NumpadArrowLeft", + "NumpadBegin", + "NumpadHome", + "NumpadEnd", + "NumpadInsert", + "NumpadDelete", + "NumpadPageUp", + "NumpadPageDown", + "Escape", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "F13", + "F14", + "F15", + "F16", + "F17", + "F18", + "F19", + "F20", + "F21", + "F22", + "F23", + "F24", + "F25", + "Fn", + "FnLock", + "PrintScreen", + "ScrollLock", + "Pause", + "BrowserBack", + "BrowserFavorites", + "BrowserForward", + "BrowserHome", + "BrowserRefresh", + "BrowserSearch", + "BrowserStop", + "Eject", + "LaunchApp1", + "LaunchApp2", + "LaunchMail", + "MediaPlayPause", + "MediaSelect", + "MediaStop", + "MediaTrackNext", + "MediaTrackPrevious", + "Power", + "Sleep", + "AudioVolumeDown", + "AudioVolumeMute", + "AudioVolumeUp", + "WakeUp", + "Copy", + "Cut", + "Paste", +] as const; + +const codeToGhosttyKey = new Map( + ghosttyKeyboardCodes.map((code, index) => [code, index]), +); + +export function ghosttyKeyForCode(code: string): number { + return codeToGhosttyKey.get(code) ?? 0; +} + +export interface GhosttyKeyboardLayoutMap { + get(code: string): string | undefined; +} + +const shiftedToUnshiftedCharacter = new Map([ + ["!", "1"], + ["@", "2"], + ["#", "3"], + ["$", "4"], + ["%", "5"], + ["^", "6"], + ["&", "7"], + ["*", "8"], + ["(", "9"], + [")", "0"], + ["~", "`"], + ["_", "-"], + ["+", "="], + ["{", "["], + ["}", "]"], + ["|", "\\"], + [":", ";"], + ['"', "'"], + ["<", ","], + [">", "."], + ["?", "/"], +]); + +let keyboardLayoutMapPromise: Promise | undefined; + +export function loadGhosttyKeyboardLayoutMap(): Promise { + if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; + const browserNavigator = globalThis.navigator as + | (Navigator & { + readonly keyboard?: { + getLayoutMap(): Promise; + }; + }) + | undefined; + const keyboard = browserNavigator?.keyboard; + const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined); + keyboardLayoutMapPromise = promise; + return promise; +} + +// Browsers do not expose consumed modifiers; treat Shift as consumed for +// unchorded character input. +export function ghosttyConsumedMods( + event: Pick, +): number { + if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0; + return [...event.key].length === 1 ? 1 : 0; +} + +export function ghosttyUnshiftedCodepoint( + event: Pick, + layoutMap?: GhosttyKeyboardLayoutMap, +): number { + if ([...event.key].length !== 1) return 0; + const layoutCharacter = layoutMap?.get(event.code); + if (layoutCharacter && [...layoutCharacter].length === 1) { + return layoutCharacter.codePointAt(0) ?? 0; + } + if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; + if (event.shiftKey) { + const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); + if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; + const lowercase = event.key.toLowerCase(); + if (lowercase !== event.key && [...lowercase].length === 1) { + return lowercase.codePointAt(0) ?? 0; + } + // Without layout data the unshifted form of a shifted key is unknowable; + // reporting the shifted character as unshifted corrupts Kitty alternate keys. + return 0; + } + return event.key.codePointAt(0) ?? 0; +} diff --git a/renderer/lib/ghostty-terminal/renderer.ts b/renderer/lib/ghostty-terminal/renderer.ts new file mode 100644 index 00000000..9d477184 --- /dev/null +++ b/renderer/lib/ghostty-terminal/renderer.ts @@ -0,0 +1,272 @@ +import { + GHOSTTY_CELL_WIDE, + ghosttyColorsEqual, + type GhosttyCell, + type GhosttyColor, + type GhosttySnapshot, +} from "./core"; + +export interface GhosttyCellMetrics { + readonly width: number; + readonly height: number; + readonly baseline: number; +} + +export interface GhosttyCellRange { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + +const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; + +function cssColor(color: GhosttyColor): string { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { + // Selection deliberately does not participate: it only tints the background + // overlay, and splitting a text run at a selection boundary visibly shifts + // glyph spacing whenever the face's true advance differs from the cell width. + return ( + ghosttyColorsEqual(left.foreground, right.foreground) && + left.bold === right.bold && + left.italic === right.italic && + left.invisible === right.invisible + ); +} + +export function ghosttyTextRunEnd( + cells: readonly GhosttyCell[], + start: number, + sameStyle: (cell: GhosttyCell) => boolean, +): number { + let end = start + 1; + while (end < cells.length) { + const next = cells[end]; + if (!next) break; + if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) { + end += 1; + continue; + } + if (next.text.length === 0 || !sameStyle(next)) break; + end += 1; + } + return end; +} + +function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string { + const style = cell.italic ? "italic" : "normal"; + const weight = cell.bold ? "700" : "400"; + return `${style} ${weight} ${fontSize}px ${fontFamily}`; +} + +export function measureGhosttyCell( + context: CanvasRenderingContext2D, + fontSize: number, + fontFamily: string, +): GhosttyCellMetrics { + context.font = `normal 400 ${fontSize}px ${fontFamily}`; + const widthMeasurement = context.measureText("M"); + const verticalMeasurement = context.measureText("Mg"); + const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize; + const descent = verticalMeasurement.actualBoundingBoxDescent; + const glyphHeight = ascent + descent; + const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); + return { + width: Math.max(1, widthMeasurement.width), + height, + baseline: Math.round((height - glyphHeight) / 2 + ascent), + }; +} + +export function terminalGridSize( + width: number, + height: number, + metrics: GhosttyCellMetrics, + padding: number, +): { cols: number; rows: number } { + return { + cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)), + rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)), + }; +} + +export function renderGhosttySnapshot(options: { + readonly context: CanvasRenderingContext2D; + readonly snapshot: GhosttySnapshot; + readonly metrics: GhosttyCellMetrics; + readonly fontSize: number; + readonly fontFamily: string; + readonly padding: number; + readonly forceFull: boolean; + readonly cursorOn: boolean; + readonly previousCursorY?: number | null; + readonly focused?: boolean; + readonly selectionBackground?: string; + readonly hoveredLinkRange?: GhosttyCellRange | null; + /** Vertical origin of row 0; defaults to the horizontal padding. */ + readonly originY?: number; +}): void { + const { + context, + snapshot, + metrics, + fontSize, + fontFamily, + padding, + forceFull, + cursorOn, + previousCursorY, + } = options; + const focused = options.focused ?? true; + const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const hoveredLinkRange = options.hoveredLinkRange ?? null; + const originY = options.originY ?? padding; + const rowsToDraw = forceFull + ? Array.from({ length: snapshot.rows }, (_, index) => index) + : [...snapshot.dirtyRows]; + if ( + previousCursorY !== null && + previousCursorY !== undefined && + previousCursorY >= 0 && + !rowsToDraw.includes(previousCursorY) + ) { + rowsToDraw.push(previousCursorY); + } + if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) { + rowsToDraw.push(snapshot.cursorY); + } + + if (forceFull) { + context.save(); + context.resetTransform(); + context.fillStyle = cssColor(snapshot.background); + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); + } + + context.textBaseline = "alphabetic"; + for (const rowIndex of rowsToDraw) { + const row = snapshot.rowData[rowIndex]; + if (!row) continue; + const top = originY + rowIndex * metrics.height; + + context.fillStyle = cssColor(snapshot.background); + context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); + + let backgroundStart = 0; + while (backgroundStart < row.cells.length) { + const first = row.cells[backgroundStart]; + if (!first) break; + let backgroundEnd = backgroundStart + 1; + while (backgroundEnd < row.cells.length) { + const next = row.cells[backgroundEnd]; + if ( + !next || + next.selected !== first.selected || + !ghosttyColorsEqual(next.background, first.background) + ) { + break; + } + backgroundEnd += 1; + } + if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) { + const left = padding + backgroundStart * metrics.width; + const width = (backgroundEnd - backgroundStart) * metrics.width; + if (!ghosttyColorsEqual(first.background, snapshot.background)) { + context.fillStyle = cssColor(first.background); + context.fillRect(left, top, width, metrics.height); + } + if (first.selected) { + context.fillStyle = selectionBackground; + context.fillRect(left, top, width, metrics.height); + } + } + backgroundStart = backgroundEnd; + } + + let runStart = 0; + while (runStart < row.cells.length) { + const first = row.cells[runStart]; + if (!first) break; + if (first.text.length === 0) { + runStart += 1; + continue; + } + const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); + const text = row.cells + .slice(runStart, runEnd) + .map((cell) => cell.text) + .join(""); + if (!first.invisible && text.trim().length > 0) { + context.save(); + context.beginPath(); + context.rect( + padding + runStart * metrics.width, + top, + (runEnd - runStart) * metrics.width, + metrics.height, + ); + context.clip(); + context.font = fontForCell(first, fontSize, fontFamily); + context.fillStyle = cssColor(first.foreground); + context.fillText( + text, + padding + runStart * metrics.width, + top + metrics.baseline, + (runEnd - runStart) * metrics.width, + ); + context.restore(); + } + runStart = runEnd; + } + + for (let column = 0; column < row.cells.length; column += 1) { + const cell = row.cells[column]; + const hoveredLink = + hoveredLinkRange !== null && + rowIndex >= hoveredLinkRange.start.y && + rowIndex <= hoveredLinkRange.end.y && + (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) && + (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x); + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) { + continue; + } + context.fillStyle = cssColor(cell.foreground); + const left = padding + column * metrics.width; + if (cell.underline || hoveredLink) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + } + if (cell.strikethrough) { + context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); + } + if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1); + } + } + + if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { + const left = padding + snapshot.cursorX * metrics.width; + const top = originY + snapshot.cursorY * metrics.height; + context.fillStyle = cssColor(snapshot.cursor); + if (!focused) { + // An unfocused terminal draws a hollow cursor so the active pane is obvious. + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else if (snapshot.cursorStyle === 0) { + context.fillRect(left, top, 2, metrics.height); + } else if (snapshot.cursorStyle === 2) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 2); + } else if (snapshot.cursorStyle === 3) { + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else { + context.fillRect(left, top, metrics.width, metrics.height); + const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; + if (cell?.text) { + context.font = fontForCell(cell, fontSize, fontFamily); + context.fillStyle = cssColor(snapshot.background); + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + } + } + } +} diff --git a/renderer/lib/ghostty-terminal/runtime.test.ts b/renderer/lib/ghostty-terminal/runtime.test.ts new file mode 100644 index 00000000..1f8fda2b --- /dev/null +++ b/renderer/lib/ghostty-terminal/runtime.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { GhosttyRuntime } from "./runtime"; +import { parseCssColor } from "./theme"; + +type WasmFunction = (...args: number[]) => number; + +test("vendored libghostty-vt stays pinned and exposes the C ABI", async () => { + const wasm = readFileSync(new URL("./vendor/ghostty-vt.wasm", import.meta.url)); + const pinned = readFileSync(new URL("./VERSION", import.meta.url), "utf8").trim(); + assert.ok(wasm.byteLength < 750_000); + + const instantiated = await WebAssembly.instantiate(wasm, { env: { log: () => undefined } }); + const instance = instantiated.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => (instance.exports[name] as WasmFunction)(...args); + const out = call("ghostty_wasm_alloc_u8_array", 8); + assert.equal(call("ghostty_build_info", 10, out), 0); + const view = new DataView(memory.buffer, out, 8); + const embeddedRevision = new TextDecoder().decode( + new Uint8Array(memory.buffer, view.getUint32(0, true), view.getUint32(4, true)), + ); + call("ghostty_wasm_free_u8_array", out, 8); + assert.equal(embeddedRevision, pinned); +}); + +test("GhosttyRuntime loads wasm, parses VT output, and encodes Enter", async () => { + const runtime = await GhosttyRuntime.load(); + const optionsSize = runtime.layout("GhosttyTerminalOptions").size; + const options = runtime.alloc(optionsSize); + runtime.setField(options, "GhosttyTerminalOptions", "cols", 40); + runtime.setField(options, "GhosttyTerminalOptions", "rows", 8); + runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", 100); + const terminalSlot = runtime.allocOpaque(); + assert.equal(runtime.call("ghostty_terminal_new", 0, terminalSlot, options), 0); + runtime.free(options, optionsSize); + const terminal = runtime.readPointer(terminalSlot); + const input = new TextEncoder().encode("Hi\x1b[31m!\x1b[0m\r\nready"); + const pointer = runtime.alloc(input.length); + runtime.bytes(pointer, input.length).set(input); + runtime.call("ghostty_terminal_vt_write", terminal, pointer, input.length); + runtime.free(pointer, input.length); + runtime.call("ghostty_terminal_free", terminal); + runtime.freeOpaque(terminalSlot); +}); + +test("theme color parsing accepts hex and rgb tokens", () => { + assert.equal(parseCssColor("#0a84ff", 0), 0x0a84ff); + assert.equal(parseCssColor("rgb(10, 132, 255)", 0), 0x0a84ff); + assert.equal(parseCssColor("not-a-color", 0x112233), 0x112233); +}); diff --git a/renderer/lib/ghostty-terminal/runtime.ts b/renderer/lib/ghostty-terminal/runtime.ts new file mode 100644 index 00000000..6310e7d5 --- /dev/null +++ b/renderer/lib/ghostty-terminal/runtime.ts @@ -0,0 +1,233 @@ +type GhosttyWasmFile = "ghostty-vt.wasm" | "ghostty-write-pty.wasm"; + +async function loadWasmBytes(filename: GhosttyWasmFile): Promise { + if (typeof document !== "undefined") { + const { VT_WASM_URL, WRITE_PTY_WASM_URL } = await import("./wasm-assets"); + const assetUrl = filename === "ghostty-vt.wasm" ? VT_WASM_URL : WRITE_PTY_WASM_URL; + const response = await fetch(assetUrl); + if (!response.ok) { + throw new Error(`Unable to load ${filename} (${response.status})`); + } + return await response.arrayBuffer(); + } + // Keep this URL dynamic so tsx/esbuild does not rewrite it to a cache path + // that has no vendor/ directory. Vite still emits wasm from wasm-assets.ts. + const vendorPath = `./vendor/${filename}`; + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + const buffer = await readFile(fileURLToPath(new URL(vendorPath, import.meta.url))); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} + +type WasmFunction = (...args: Array) => number; + +interface TypeField { + readonly offset: number; + readonly size: number; + readonly type: string; +} + +interface TypeLayout { + readonly size: number; + readonly align: number; + readonly fields: Readonly>; +} + +type TypeLayouts = Readonly>; + +const textDecoder = new TextDecoder(); + +export class GhosttyRuntime { + readonly memory: WebAssembly.Memory; + readonly layouts: TypeLayouts; + private readonly exports: WebAssembly.Exports; + private readonly ptyWriters = new Map void>(); + private nextPtyWriterId = 1; + private writePtyFunctionIndex = 0; + + private constructor(instance: WebAssembly.Instance) { + this.exports = instance.exports; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("libghostty-vt did not export WebAssembly memory"); + } + this.memory = memory; + const jsonPointer = this.call("ghostty_type_json"); + const bytes = new Uint8Array(memory.buffer); + let end = jsonPointer; + while (end < bytes.length && bytes[end] !== 0) end += 1; + this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts; + } + + static async load(): Promise { + const wasmBytes = await loadWasmBytes("ghostty-vt.wasm"); + let instance: WebAssembly.Instance | undefined; + const imports = { + env: { + log: (pointer: number, length: number) => { + if (!instance) return; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) return; + const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length)); + console.debug("[libghostty-vt]", message); + }, + }, + }; + const result = await WebAssembly.instantiate(wasmBytes, imports); + instance = result.instance; + const runtime = new GhosttyRuntime(result.instance); + await runtime.installWritePtyTrampoline(); + return runtime; + } + + call(name: string, ...args: Array): number { + const fn = this.exports[name]; + if (typeof fn !== "function") { + throw new Error(`libghostty-vt export is unavailable: ${name}`); + } + return (fn as WasmFunction)(...args); + } + + layout(name: string): TypeLayout { + const layout = this.layouts[name]; + if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`); + return layout; + } + + alloc(size: number): number { + const pointer = this.call("ghostty_wasm_alloc_u8_array", size); + if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`); + new Uint8Array(this.memory.buffer, pointer, size).fill(0); + return pointer; + } + + free(pointer: number, size: number): void { + if (pointer !== 0) this.call("ghostty_wasm_free_u8_array", pointer, size); + } + + allocOpaque(): number { + const pointer = this.call("ghostty_wasm_alloc_opaque"); + if (pointer === 0) throw new Error("libghostty-vt failed to allocate an opaque pointer"); + // The slot is uninitialized until a *_new call writes it; zero it so dispose + // paths that run after a partial initialization never free a garbage pointer. + new DataView(this.memory.buffer).setUint32(pointer, 0, true); + return pointer; + } + + freeOpaque(pointer: number): void { + if (pointer !== 0) this.call("ghostty_wasm_free_opaque", pointer); + } + + readPointer(slot: number): number { + return new DataView(this.memory.buffer).getUint32(slot, true); + } + + attachPtyWriter(terminal: number, writer: (data: string) => void): number { + if (this.writePtyFunctionIndex === 0) { + throw new Error("libghostty-vt PTY callback trampoline is unavailable"); + } + const id = this.nextPtyWriterId++; + this.ptyWriters.set(id, writer); + this.call("ghostty_terminal_set", terminal, 0, id); + this.call("ghostty_terminal_set", terminal, 1, this.writePtyFunctionIndex); + return id; + } + + detachPtyWriter(terminal: number, id: number): void { + this.call("ghostty_terminal_set", terminal, 1, 0); + this.call("ghostty_terminal_set", terminal, 0, 0); + this.ptyWriters.delete(id); + } + + view(pointer: number, size?: number): DataView { + return new DataView(this.memory.buffer, pointer, size); + } + + bytes(pointer: number, size: number): Uint8Array { + return new Uint8Array(this.memory.buffer, pointer, size); + } + + setField(pointer: number, structName: string, fieldName: string, value: number): void { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.view(pointer + field.offset, field.size); + switch (field.type) { + case "bool": + case "u8": + view.setUint8(0, value); + return; + case "u16": + view.setUint16(0, value, true); + return; + case "i32": + view.setInt32(0, value, true); + return; + case "u32": + case "enum": + view.setUint32(0, value, true); + return; + case "u64": + view.setBigUint64(0, BigInt(value), true); + return; + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + readField(pointer: number, structName: string, fieldName: string): number { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.view(pointer + field.offset, field.size); + switch (field.type) { + case "bool": + case "u8": + return view.getUint8(0); + case "u16": + return view.getUint16(0, true); + case "i32": + return view.getInt32(0, true); + case "u32": + case "enum": + return view.getUint32(0, true); + case "u64": + return Number(view.getBigUint64(0, true)); + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + private async installWritePtyTrampoline(): Promise { + const trampolineBytes = await loadWasmBytes("ghostty-write-pty.wasm"); + const result = await WebAssembly.instantiate(trampolineBytes, { + env: { + t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { + const writer = this.ptyWriters.get(userdata); + if (!writer || length === 0) return; + writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length))); + }, + }, + }); + const trampoline = result.instance.exports.ghostty_write_pty; + const table = this.exports.__indirect_function_table; + if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) { + throw new Error("libghostty-vt did not expose its callback table"); + } + const index = table.length; + // grow-then-set instead of grow(1, fn): WebKit stores a grow init value + // with broken type information and every later call_indirect through the + // entry traps with a signature mismatch. table.set canonicalizes correctly. + table.grow(1); + table.set(index, trampoline); + this.writePtyFunctionIndex = index; + } +} + +let runtimePromise: Promise | null = null; + +export function loadGhosttyRuntime(): Promise { + runtimePromise ??= GhosttyRuntime.load().catch((error) => { + runtimePromise = null; + throw error; + }); + return runtimePromise; +} diff --git a/renderer/lib/ghostty-terminal/surface.ts b/renderer/lib/ghostty-terminal/surface.ts new file mode 100644 index 00000000..cb0de42c --- /dev/null +++ b/renderer/lib/ghostty-terminal/surface.ts @@ -0,0 +1,1901 @@ +import { isMacPlatform } from "./host-platform"; +import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "./terminal-links"; +import { + GhosttyTerminalCore, + type GhosttyScrollbar, + type GhosttySnapshot, + type GhosttyTheme, +} from "./core"; +import { + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, + type GhosttyCellRange, + type GhosttyCellMetrics, +} from "./renderer"; +import { isMonospaceFamily } from "./appearance-fonts"; +import "./wasm-assets"; + +const symbolsFontUrl = new URL("./fonts/SymbolsNerdFontMono-Regular.woff2", import.meta.url).href; + +export const DEFAULT_TERMINAL_FONT_SIZE = 12; +const MIN_TERMINAL_FONT_SIZE = 6; +const MAX_TERMINAL_FONT_SIZE = 32; +// The glyph fallbacks only supply symbols the text faces are missing (powerline +// separators, devicons, and other private-use prompt symbols), so shells +// configured for a locally installed Nerd Font keep their prompt glyphs no +// matter which text face is active. +const TERMINAL_GLYPH_FALLBACKS = + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; +// The platform's own monospace faces; concrete names only, because an +// unknown keyword (like ui-monospace) makes canvas font shorthand parsing +// reject the whole string. +export const DEFAULT_TERMINAL_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; +const CONTENT_PADDING = 4; +const MIN_SCROLLBAR_THUMB_HEIGHT = 18; +/** Half a blink cycle: the visible and hidden phases are equally long. */ +const CURSOR_BLINK_INTERVAL_MS = 500; +const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; +const TERMINAL_FONT_LOAD_VARIANTS = [ + "normal 400", + "normal 700", + "italic 400", + "italic 700", +] as const; + +/** Requested terminal font; omitted fields fall back to the defaults. */ +export interface GhosttyTerminalFont { + readonly family?: string; + readonly size?: number; +} + +let symbolsFontLoad: Promise | null = null; + +/** + * Register the bundled symbols-only Nerd Font once per page. It loads lazily + * with the first terminal, and because it carries no regular text glyphs it + * composes with any text face without changing metrics — prompt symbols and + * devicons render even on machines without a locally installed Nerd Font. + */ +function ensureTerminalSymbolsFont(): Promise { + if (symbolsFontLoad !== null) return symbolsFontLoad; + symbolsFontLoad = (async () => { + try { + const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); + document.fonts.add(await face.load()); + } catch { + // Locally installed fallback faces still apply. + } + })(); + return symbolsFontLoad; +} + +function quoteTerminalFontFamilies(list: string): string { + return list + .split(",") + .map((name) => { + const bare = name.trim(); + if (bare.length === 0) return ""; + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replace(/"/gu, "")}"`; + }) + .filter((name) => name.length > 0) + .join(", "); +} + +function uncheckedTerminalFontFamily(family?: string): string { + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + return custom.length === 0 + ? DEFAULT_TERMINAL_FONT_FAMILY + : `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; +} + +export function terminalFontFamily(family?: string): string { + // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes + // the whole canvas font string invalid and the assignment silently no-ops. + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; + // The grid places the cursor and selection on one cell advance, so a + // proportional face would draw its text narrower than its own cells. Refuse + // it here rather than render a ragged grid with a stranded cursor. + if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; + // A custom face keeps the glyph fallbacks so prompt symbols stay covered. + return uncheckedTerminalFontFamily(custom); +} + +/** Load every style the renderer can request, then validate the actual face. */ +export async function loadTerminalFontFamily( + family: string | undefined, + size: number, + environment?: { + readonly load: (font: string, text: string) => Promise; + readonly resolve: (family: string | undefined) => string; + }, +): Promise { + const candidate = uncheckedTerminalFontFamily(family); + const load = + environment?.load ?? ((font: string, text: string) => document.fonts.load(font, text)); + try { + await Promise.all( + TERMINAL_FONT_LOAD_VARIANTS.map((variant) => + load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), + ), + ); + } catch { + // The fixed-width fallback stack remains available if a face cannot load. + } + return (environment?.resolve ?? terminalFontFamily)(family); +} + +export function terminalFontSize(size?: number): number { + if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; + return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); +} + +/** + * Whether the cursor should keep toggling. An unfocused surface draws a steady + * hollow cursor instead of blinking, and a reduced-motion reader gets a steady + * cursor too rather than a permanently animating element. + */ +export function shouldBlinkTerminalCursor(state: { + readonly focused: boolean; + readonly cursorBlinking: boolean; + readonly cursorVisible: boolean; + readonly reducedMotion: boolean; +}): boolean { + return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion; +} + +/** + * Vertical origin of the grid inside the mount. While content is shorter than + * the viewport the grid sits at the top like a fresh terminal. Once scrollback + * exists the prompt lives on the bottom row, so the grid anchors to the bottom + * edge instead: the sub-row remainder moves above row 0 and resizing within a + * row boundary keeps the prompt pinned instead of snapping up and down. + */ +export function terminalContentOriginY( + mountHeight: number, + padding: number, + rows: number, + cellHeight: number, + anchorBottom: boolean, +): number { + if (!anchorBottom) return padding; + const slack = mountHeight - padding * 2 - rows * cellHeight; + return padding + Math.max(0, slack); +} + +export interface TerminalScrollbarGeometry { + readonly thumbHeight: number; + readonly thumbTop: number; + readonly maxOffset: number; +} + +export function terminalScrollbarGeometry( + state: GhosttyScrollbar, + trackHeight: number, +): TerminalScrollbarGeometry | null { + const total = Math.max(0, state.total); + const len = Math.max(0, Math.min(state.len, total)); + const maxOffset = Math.max(0, total - len); + if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; + const thumbHeight = Math.min( + trackHeight, + Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), + ); + const travel = Math.max(0, trackHeight - thumbHeight); + const offset = Math.max(0, Math.min(state.offset, maxOffset)); + return { + thumbHeight, + thumbTop: travel * (offset / maxOffset), + maxOffset, + }; +} + +export function terminalScrollbarOffsetAtPointer( + state: GhosttyScrollbar, + trackHeight: number, + pointerY: number, + pointerOffset: number, +): number { + const geometry = terminalScrollbarGeometry(state, trackHeight); + if (geometry === null) return 0; + const travel = Math.max(0, trackHeight - geometry.thumbHeight); + if (travel === 0) return 0; + const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); + return Math.round((thumbTop / travel) * geometry.maxOffset); +} + +export function terminalGridCellAt(options: { + bounds: { left: number; top: number }; + clientX: number; + clientY: number; + cols: number; + rows: number; + metrics: Pick; + padding: number; + originY: number; +}): { x: number; y: number } | null { + const { bounds, clientX, clientY, cols, rows, metrics, padding, originY } = options; + const gridX = clientX - bounds.left - padding; + const gridY = clientY - bounds.top - originY; + if (gridX < 0 || gridY < 0 || gridX >= cols * metrics.width || gridY >= rows * metrics.height) { + return null; + } + return { + x: Math.floor(gridX / metrics.width), + y: Math.floor(gridY / metrics.height), + }; +} + +function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { + const text = row.cells.map((cell) => cell.text || " ").join(""); + return trimRight ? text.trimEnd() : text; +} + +function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { + let offset = 0; + for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { + offset += row.cells[cellIndex]?.text.length || 1; + } + return offset; +} + +export function terminalLinkAtPosition( + rows: GhosttySnapshot["rowData"], + rowIndex: number, + column: number, +): string | null { + return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; +} + +export interface TerminalLinkWithRange { + readonly text: string; + readonly range: GhosttyCellRange; +} + +function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { + for (let column = 0; column < row.cells.length; column += 1) { + const nextOffset = terminalColumnOffset(row, column + 1); + if (offset < nextOffset) return column; + } + return Math.max(0, row.cells.length - 1); +} + +export function terminalLinkAtPositionWithRange( + rows: GhosttySnapshot["rowData"], + rowIndex: number, + column: number, +): TerminalLinkWithRange | null { + const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { + const row = rows[index]; + if (!row) return null; + return { + isWrapped: row.isWrapContinuation, + translateToString: (trimRight = false) => terminalRowText(row, trimRight), + }; + }); + if (!wrappedLine) return null; + // Only viewport rows are available: a wrapped line whose head scrolled above + // the viewport would resolve a truncated match into a wrong link. + const firstSegment = wrappedLine.segments[0]; + if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { + return null; + } + const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); + const row = rows[rowIndex]; + if (!segment || !row) return null; + const lastSegment = wrappedLine.segments[wrappedLine.segments.length - 1]; + const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; + // Ghostty's soft-wrap flag is authoritative: when the last collected row + // still wraps onward, its continuation is outside the viewport. + const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; + const offset = segment.startIndex + terminalColumnOffset(row, column); + for (const match of extractTerminalLinks(wrappedLine.text)) { + if (offset >= match.start && offset < match.end) { + // A truncated tail must not activate as a complete link. + if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; + const startSegment = wrappedLine.segments.find( + (value) => match.start >= value.startIndex && match.start < value.endIndex, + ); + const endSegment = wrappedLine.segments.find( + (value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex, + ); + const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; + const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; + if (!startSegment || !endSegment || !startRow || !endRow) return null; + return { + text: match.text, + range: { + start: { + x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex), + y: startSegment.bufferLineNumber - 1, + }, + end: { + x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + y: endSegment.bufferLineNumber - 1, + }, + }, + }; + } + } + return null; +} + +export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { + return terminalLinkAtPosition([row], 0, column); +} + +export function isTerminalCopyShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== "c") return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; +} + +/** + * Canvas terminals have no DOM selection. Native copy and Electron's Edit + * menu `role: "copy"` both read the focused textarea, so an empty IME field + * writes blankness to the clipboard. Park the Ghostty selection there first. + */ +export function primeTerminalCopyInput( + input: Pick, + selection: string, +): void { + input.value = selection; + if (selection.length === 0) return; + input.select(); +} + +export function clearPrimedTerminalCopyInput( + input: Pick, + primedSelection: string, +): void { + // Only blank the copy we parked. The same textarea holds the IME candidate; + // wiping whatever is there would cancel CJK composition. + if (primedSelection.length === 0 || input.value !== primedSelection) return; + input.value = ""; +} + +/** + * Only a copy event that actually received the selection may cancel the + * clipboard.writeText fallback. Claiming without clipboardData (Electron's + * menu Copy) used to preventDefault an empty write and skip the fallback, + * which is how Cmd+C copied blankness. + */ +export function applyTerminalCopyEvent( + selection: string, + clipboardData: { setData: (type: string, data: string) => void } | null | undefined, +): { preventDefault: boolean; claimWriteFallback: boolean } { + if (selection.length === 0 || !clipboardData) { + return { preventDefault: false, claimWriteFallback: false }; + } + clipboardData.setData("text/plain", selection); + return { preventDefault: true, claimWriteFallback: true }; +} + +export function isTerminalPasteShortcut( + event: Pick, + platform = navigator.platform, +) { + const key = event.key.toLowerCase(); + if (key === "insert" && !isMacPlatform(platform)) { + return event.shiftKey && !event.ctrlKey && !event.metaKey; + } + if (key !== "v") return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; +} + +export function isTerminalCompositionCommitInput(event: Pick): boolean { + return ( + event.inputType === "" || + event.inputType === "insertCompositionText" || + event.inputType === "insertFromComposition" + ); +} + +/** IME keydowns must not touch the hidden textarea; it holds the candidate. */ +export function isTerminalCompositionKey( + event: Pick, + composing: boolean, +): boolean { + return event.isComposing || composing || event.key === "Process" || event.keyCode === 229; +} + +export function isTerminalAltGraphText( + event: Pick, +): boolean { + return event.getModifierState("AltGraph") && [...event.key].length === 1; +} + +export function shouldReportTerminalMouse( + tracking: boolean, + event: Pick, +): boolean { + return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; +} + +type TerminalMouseAction = "press" | "release" | "motion"; + +export function resolveTerminalMouseData( + action: TerminalMouseAction, + data: string, + previousMotionData: string, +): { readonly send: boolean; readonly nextMotionData: string } { + const nextMotionData = action === "motion" ? data : ""; + return { + send: data.length > 0 && (action !== "motion" || data !== previousMotionData), + nextMotionData, + }; +} + +export function resolveTerminalMouseTrackingState( + previousTracking: boolean, + tracking: boolean, + motionData: string, +): { readonly tracking: boolean; readonly motionData: string } { + return { + tracking, + motionData: previousTracking === tracking ? motionData : "", + }; +} + +export function terminalWheelDeltaRows( + event: Pick, + cellHeight: number, + viewportRows: number, + remainder: number, +): { readonly rows: number; readonly remainder: number } { + // deltaMode: 0 pixels, 1 lines, 2 pages. + const pixels = + event.deltaMode === 1 + ? event.deltaY * cellHeight + : event.deltaMode === 2 + ? event.deltaY * viewportRows * cellHeight + : event.deltaY; + const total = remainder + pixels / cellHeight; + const rows = Math.trunc(total); + return { rows, remainder: total - rows }; +} + +export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { + if (rows === 0) return ""; + const sequence = + rows < 0 + ? applicationCursorKeys + ? "\u001bOA" + : "\u001b[A" + : applicationCursorKeys + ? "\u001bOB" + : "\u001b[B"; + return sequence.repeat(Math.abs(rows)); +} + +export function isTerminalLinkPointerGesture( + event: Pick, + platform = navigator.platform, +): boolean { + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function ghosttyMouseButton(button: number): number | null { + switch (button) { + case 0: + return 1; + case 1: + return 3; + case 2: + return 2; + case 3: + return 4; + case 4: + return 5; + default: + return null; + } +} + +export interface TerminalSelectionClickSequence { + readonly count: number; + readonly time: number; + readonly x: number; + readonly y: number; +} + +export function advanceTerminalSelectionClickSequence( + previous: TerminalSelectionClickSequence | null, + event: Pick, +): TerminalSelectionClickSequence { + const repeats = + previous !== null && + event.timeStamp - previous.time <= 500 && + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; + return { + count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, + time: event.timeStamp, + x: event.clientX, + y: event.clientY, + }; +} + +export interface GhosttySelectionPosition { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + +export interface GhosttyTerminalSurfaceOptions { + readonly theme: GhosttyTheme; + readonly font?: GhosttyTerminalFont; + readonly onData: (data: string) => void; + readonly onResize: (cols: number, rows: number) => void; + readonly onSelectionChange: () => void; + readonly beforeKey: (event: KeyboardEvent) => boolean; + readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; +} + +export class GhosttyTerminalSurface { + readonly canvas: HTMLCanvasElement; + readonly input: HTMLTextAreaElement; + readonly scrollbar: HTMLDivElement; + cols = 1; + rows = 1; + + private readonly mount: HTMLElement; + private readonly context: CanvasRenderingContext2D; + private readonly core: GhosttyTerminalCore; + private readonly options: GhosttyTerminalSurfaceOptions; + private metrics: GhosttyCellMetrics; + private fontFamily: string; + private requestedFontFamily: string | undefined; + private fontSize: number; + private fontEpoch = 0; + private pendingFontEpoch: number | null = null; + private readonly resizeObserver: ResizeObserver; + private readonly scrollbarThumb: HTMLDivElement; + private snapshot: GhosttySnapshot | null = null; + private frame = 0; + private cursorTimer: number | null = null; + private compositionInputToSuppress: string | null = null; + private compositionSuppressionTimer: number | null = null; + private cursorOn = true; + private renderedCursorY: number | null = null; + private forceFullRender = true; + private scrollbarDirty = true; + private scrollbarState: GhosttyScrollbar | null = null; + private scrollbarPointerId: number | null = null; + private scrollbarPointerOffset = 0; + private disposed = false; + private resizeNotifyTimer: number | null = null; + private originY = CONTENT_PADDING; + private mountHeight = 0; + private selectionEnd: { x: number; y: number } | null = null; + private selectionAnchorScreen: { x: number; y: number } | null = null; + private selectionEndScreen: { x: number; y: number } | null = null; + private selectionMode: "cell" | "word" | "line" = "cell"; + // Word/line selection base in screen coordinates so streaming output cannot + // shift the origin of a drag selection. + private selectionBase: { + start: { x: number; y: number }; + end: { x: number; y: number }; + } | null = null; + private selectionScrollTimer: number | null = null; + private selectionScrollDelta = 0; + private selectionPointer: { x: number; y: number } | null = null; + private mouseReportingPointerId: number | null = null; + private mouseReportingButton: number | null = null; + private linkActivationPointerId: number | null = null; + private hoveredLink: TerminalLinkWithRange | null = null; + private hoverPointer: { x: number; y: number } | null = null; + private linkModifierActive = false; + private selectionClickSequence: TerminalSelectionClickSequence | null = null; + private selectionMoved = false; + private composing = false; + private focused = false; + private resizeNotified = false; + private canvasConfigured = false; + private theme: GhosttyTheme; + private readonly suppressedKeyCodes = new Set(); + private pasteShortcutToken = 0; + private copyShortcutToken = 0; + private clearSelectionAfterCopy = false; + private primedCopySelection = ""; + private wheelRemainder = 0; + private lastMouseMotionData = ""; + private mouseAnyEventTracking = false; + private dprMedia: MediaQueryList | null = null; + // Read live on every blink decision, and watched so that dropping the + // preference restarts a blink cycle that has no timer left to notice it. + private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + private inputLeft = -1; + private inputTop = -1; + + private constructor( + mount: HTMLElement, + canvas: HTMLCanvasElement, + input: HTMLTextAreaElement, + scrollbar: HTMLDivElement, + scrollbarThumb: HTMLDivElement, + context: CanvasRenderingContext2D, + core: GhosttyTerminalCore, + metrics: GhosttyCellMetrics, + fontFamily: string, + options: GhosttyTerminalSurfaceOptions, + ) { + this.mount = mount; + this.canvas = canvas; + this.input = input; + this.scrollbar = scrollbar; + this.scrollbarThumb = scrollbarThumb; + this.context = context; + this.core = core; + this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); + this.metrics = metrics; + this.options = options; + this.theme = options.theme; + this.fontFamily = fontFamily; + this.requestedFontFamily = options.font?.family; + this.fontSize = terminalFontSize(options.font?.size); + this.resizeObserver = new ResizeObserver(() => this.fit()); + this.installEvents(); + this.watchDevicePixelRatio(); + this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); + document.fonts.addEventListener("loadingdone", this.onFontsLoaded); + this.resizeObserver.observe(mount); + } + + static async create( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, + ): Promise { + const canvas = document.createElement("canvas"); + canvas.className = "block size-full cursor-text"; + canvas.setAttribute("aria-hidden", "true"); + mount.classList.add("ghostty-screen"); + + const input = document.createElement("textarea"); + input.className = "ghostty-input"; + input.setAttribute("aria-label", "Terminal input"); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.style.cssText = + "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; + + const scrollbar = document.createElement("div"); + scrollbar.className = + "group absolute top-1 right-px bottom-1 z-1 w-1.5 cursor-default touch-none"; + scrollbar.setAttribute("role", "scrollbar"); + scrollbar.setAttribute("aria-label", "Terminal scrollback"); + scrollbar.setAttribute("aria-orientation", "vertical"); + scrollbar.tabIndex = 0; + scrollbar.hidden = true; + const scrollbarThumb = document.createElement("div"); + scrollbarThumb.className = + "absolute inset-x-px top-0 rounded-[3px] bg-current/40 text-primary transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-current/55 group-focus-visible:bg-current/55"; + scrollbar.append(scrollbarThumb); + mount.replaceChildren(canvas, input, scrollbar); + + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Canvas 2D is unavailable"); + // An opaque canvas backing store initializes to solid black, and the font + // and WASM loads below leave it on screen for the whole setup window; paint + // the theme background first so the mount never flashes a black box. + context.fillStyle = `rgb(${options.theme.background.r}, ${options.theme.background.g}, ${options.theme.background.b})`; + context.fillRect(0, 0, canvas.width, canvas.height); + const fontSize = terminalFontSize(options.font?.size); + try { + // Cell metrics must come from the faces that will render; measuring before + // the bundled webfonts load would size the grid from a fallback font. + await ensureTerminalSymbolsFont(); + } catch { + // Metrics fall back to whichever faces are already available. + } + const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); + const metrics = measureGhosttyCell(context, fontSize, fontFamily); + const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); + const core = await GhosttyTerminalCore.create( + grid.cols, + grid.rows, + metrics.width, + metrics.height, + options.theme, + options.onData, + ); + const surface = new GhosttyTerminalSurface( + mount, + canvas, + input, + scrollbar, + scrollbarThumb, + context, + core, + metrics, + fontFamily, + options, + ); + surface.fit(); + surface.requestRender(); + return surface; + } + + write(data: string): void { + if (this.disposed) return; + this.core.write(data); + this.synchronizeMouseTrackingState(); + // Restart the blink cycle from the visible phase so the cursor never sits + // invisible through a stream of output or a burst of typing echo. + this.cursorOn = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + resetAndWrite(data: string): void { + if (this.disposed) return; + this.lastMouseMotionData = ""; + this.core.resetAndWrite(data); + this.synchronizeMouseTrackingState(); + // A replayed session starts from the visible phase like any other write: + // reattaching mid-blink must not open on an invisible cursor. + this.cursorOn = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + setTheme(theme: GhosttyTheme): void { + if (this.disposed) return; + this.theme = theme; + this.core.setTheme(theme); + this.forceFullRender = true; + this.requestRender(); + } + + async setFont(font: GhosttyTerminalFont): Promise { + if (this.disposed) return; + const fontSize = terminalFontSize(font.size); + // The fields only change together with their metrics after the load, and + // the epoch lets the newest overlapping call win regardless of load order. + const epoch = ++this.fontEpoch; + this.pendingFontEpoch = epoch; + const fontFamily = await loadTerminalFontFamily(font.family, fontSize); + if (this.disposed || epoch !== this.fontEpoch) return; + this.pendingFontEpoch = null; + this.fontFamily = fontFamily; + this.requestedFontFamily = font.family; + this.fontSize = fontSize; + this.applyFontMetrics(); + } + + private applyFontMetrics(): void { + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + // Cached IME textarea coordinates are stale in the new cell geometry. + this.inputLeft = -1; + this.inputTop = -1; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.fit(); + this.requestRender(); + } + + private readonly onReducedMotionChange = () => { + if (this.disposed) return; + // Nothing else wakes an idle steady cursor: the blink timer only reschedules + // from a render, and reduced motion is exactly the state that stopped it. + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onFontsLoaded = () => { + if (this.disposed) return; + // The explicit load validates every style and applies the newest request. + // Its own loading events must not revalidate the previously applied face. + if (this.pendingFontEpoch !== null) return; + // A face may become available after an earlier fallback measurement. Run + // the fixed-width guard again before using its newly loaded metrics. + const fontFamily = terminalFontFamily(this.requestedFontFamily); + if (fontFamily !== this.fontFamily) { + this.fontFamily = fontFamily; + this.applyFontMetrics(); + return; + } + // A face that finished loading after the initial measurement changes glyph + // advances; re-measure and refit so the grid matches what actually renders. + const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + if ( + metrics.width === this.metrics.width && + metrics.height === this.metrics.height && + metrics.baseline === this.metrics.baseline + ) { + return; + } + this.applyFontMetrics(); + }; + + fit(): boolean { + if (this.disposed) return false; + const width = this.mount.clientWidth; + const height = this.mount.clientHeight; + if (width <= 0 || height <= 0) return false; + const ratio = window.devicePixelRatio || 1; + const pixelWidth = Math.max(1, Math.round(width * ratio)); + const pixelHeight = Math.max(1, Math.round(height * ratio)); + let shouldRender = false; + // The DPR transform must be installed even when the target size happens to + // equal the canvas default 300x150 backing store, so the first fit always + // schedules a canvas configuration. + if ( + this.canvas.width !== pixelWidth || + this.canvas.height !== pixelHeight || + !this.canvasConfigured + ) { + this.canvas.width = pixelWidth; + this.canvas.height = pixelHeight; + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.canvasConfigured = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); + this.mountHeight = height; + // onResize is the only PTY resize channel, so the first successful fit must + // notify even when the measured grid equals the 1x1 construction sentinel. + if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { + this.cols = grid.cols; + this.rows = grid.rows; + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + this.notifyResize(); + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + // Rendering synchronously keeps the repaint inside the same frame as the + // layout change: ResizeObserver fires before paint, so the browser never + // composites the old backing store stretched into the new element box. + if (shouldRender) this.renderFrame(); + return true; + } + + /** + * The local grid reflows immediately, but the PTY only hears about settled + * dimensions: notifying on every drag step makes the shell reprint its + * prompt mid-drag, which reads as jitter. + */ + private notifyResize(): void { + this.resizeNotified = true; + if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = window.setTimeout(() => { + this.resizeNotifyTimer = null; + if (!this.disposed) this.options.onResize(this.cols, this.rows); + }, 150); + } + + focus(): void { + this.input.focus({ preventScroll: true }); + } + + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + + hasSelection(): boolean { + return this.core.selectionText().length > 0; + } + + getSelection(): string { + return this.core.selectionText(); + } + + getSelectionPosition(): GhosttySelectionPosition | null { + if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) + return null; + const before = + this.selectionAnchorScreen.y < this.selectionEndScreen.y || + (this.selectionAnchorScreen.y === this.selectionEndScreen.y && + this.selectionAnchorScreen.x <= this.selectionEndScreen.x); + return before + ? { start: this.selectionAnchorScreen, end: this.selectionEndScreen } + : { start: this.selectionEndScreen, end: this.selectionAnchorScreen }; + } + + getSelectionEndClientRect(): { readonly right: number; readonly bottom: number } | null { + const position = this.getSelectionPosition(); + if (!position) return null; + const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y); + if (!viewportEnd) return null; + const bounds = this.canvas.getBoundingClientRect(); + return { + right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, + bottom: bounds.top + this.originY + (viewportEnd.y + 1) * this.metrics.height, + }; + } + + clearSelection(): void { + this.clearPrimedCopy(); + this.core.clearSelection(); + this.selectionEnd = null; + this.selectionAnchorScreen = null; + this.selectionEndScreen = null; + this.selectionMode = "cell"; + this.selectionBase = null; + this.setSelectionAutoscroll(0); + this.options.onSelectionChange(); + // Selection highlights span rows Ghostty may not mark dirty for this change. + this.forceFullRender = true; + this.requestRender(); + } + + scrollToBottom(): void { + this.core.scrollToBottom(); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + isAtBottom(): boolean { + return this.core.isViewportActive(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.resizeObserver.disconnect(); + document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); + this.dprMedia = null; + this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); + if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + if (this.resizeNotifyTimer !== null) { + window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = null; + // Flush the settled dimensions so the PTY keeps the final size even when + // the surface unmounts inside the debounce window. + this.options.onResize(this.cols, this.rows); + } + if (this.frame !== 0) window.cancelAnimationFrame(this.frame); + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + } + this.removeEvents(); + this.core.dispose(); + if ( + this.canvas.parentElement === this.mount || + this.input.parentElement === this.mount || + this.scrollbar.parentElement === this.mount + ) { + this.canvas.remove(); + this.input.remove(); + this.scrollbar.remove(); + } + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + this.updateLinkModifier(event); + // Presses handled outside the terminal must also swallow their release: + // beforeKey runs side effects (keybindings, navigation sends), so it cannot + // be consulted again on keyup, and Kitty report-event-types sessions would + // otherwise receive a release for a press the shell never saw. + if (isTerminalAltGraphText(event) || !this.options.beforeKey(event)) { + this.suppressedKeyCodes.add(event.code); + return; + } + if (isTerminalCopyShortcut(event) && this.hasSelection()) { + // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in + // onCopyEvent; not preventing the default keeps that path alive. WebKit + // omits the keyboard copy event without a DOM selection, so race the + // clipboard write against it the same way paste races its read. The + // Shift variant has no native event (Chrome binds Ctrl+Shift+C to + // inspect), so synthesize one with execCommand("copy"). + const selection = this.getSelection(); + this.primeCopy(selection); + if (event.shiftKey) { + event.preventDefault(); + document.execCommand("copy"); + } else { + // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once + // it copies so the next Ctrl+C reaches the shell. The Shift chord and + // Cmd+C are copy-only, so they keep the selection; resetting the flag + // up front also drops any clear owed by an earlier gesture that never + // completed. + this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform); + const clipboard = navigator.clipboard; + if (typeof clipboard?.writeText === "function") { + // Defer the write past the default action: the native copy event + // (dispatched synchronously with the default action) claims the + // token first when it actually writes, and the write covers browsers + // whose shortcut produces no copy event. The primed textarea is what + // Electron's edit-menu Copy reads if it runs after this handler. + const token = ++this.copyShortcutToken; + void Promise.resolve().then(() => { + if (this.disposed || this.copyShortcutToken !== token) return; + void clipboard.writeText(selection).then( + () => { + // The write may have been superseded while in flight; only + // touch the selection if this gesture still owns the token. + if (this.disposed || this.copyShortcutToken !== token) return; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + }, + () => { + // The write failed and the native event has already had its + // chance, so nothing copied and no clear is owed by this + // gesture; a newer one may have just set the flag, so only + // drop it if this gesture still owns the token. + if (this.copyShortcutToken === token) { + this.clearSelectionAfterCopy = false; + } + }, + ); + }); + } + } + this.suppressedKeyCodes.add(event.code); + return; + } + if (isTerminalPasteShortcut(event)) { + this.suppressedKeyCodes.add(event.code); + const clipboard = navigator.clipboard; + if (typeof clipboard?.readText === "function") { + // Race the async clipboard read against the browser's own paste event: + // the native event (dispatched synchronously with the default action) + // always claims the token first when it fires, and the read covers + // browsers whose paste shortcut produces no paste event. Not preventing + // the default keeps the native path alive when the read is denied. + const token = ++this.pasteShortcutToken; + void clipboard.readText().then( + (text) => { + if (this.disposed || this.pasteShortcutToken !== token) return; + this.pasteShortcutToken += 1; + if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + }, + () => { + // Clipboard read denied; the native paste event remains the path. + }, + ); + } + return; + } + // keyCode 229 is Safari's only signal that this keydown opens an IME + // composition; encoding it would double the committed text. Do not blank + // the textarea first: onInput leaves the in-progress candidate there. + if (isTerminalCompositionKey(event, this.composing)) { + return; + } + this.clearPrimedCopy(); + const data = this.core.encodeKey(event); + if (data.length === 0) return; + this.suppressedKeyCodes.delete(event.code); + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onKeyUp = (event: KeyboardEvent) => { + this.updateLinkModifier(event); + if (this.suppressedKeyCodes.delete(event.code)) return; + if (isTerminalCompositionKey(event, this.composing)) { + return; + } + // Ghostty's encoder only emits release codes when the terminal enabled the + // Kitty report-event-types flag, so legacy sessions send nothing here. + const data = this.core.encodeKey(event, "release"); + if (data.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onFocus = () => { + this.focused = true; + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onBlur = () => { + this.focused = false; + this.linkModifierActive = false; + this.refreshHoveredLink(); + // Suppressions survive blur deliberately: a shortcut that moves focus (for + // example terminal-toggle) must still swallow its own keyup if focus comes + // back before release. Stale entries are harmless — an encoding keydown + // always removes its code first. + // The steady unfocused hollow cursor must not inherit an off blink phase. + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onDevicePixelRatioChange = () => { + this.watchDevicePixelRatio(); + this.fit(); + }; + + private watchDevicePixelRatio(): void { + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); + // A resolution media query only fires once for the ratio it was created at, + // so re-arm it after every change (monitor moves, browser zoom). + this.dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); + } + + private primeCopy(selection: string): void { + this.primedCopySelection = selection; + primeTerminalCopyInput(this.input, selection); + } + + private clearPrimedCopy(): void { + clearPrimedTerminalCopyInput(this.input, this.primedCopySelection); + this.primedCopySelection = ""; + } + + private readonly onCopyEvent = (event: ClipboardEvent) => { + const selection = this.hasSelection() ? this.getSelection() : this.input.value; + // Menu-role Copy never hits the keydown primer. The native action reads + // this.input, so park the current selection first — including when + // clipboardData is missing and we must not preventDefault. + this.primeCopy(selection); + const result = applyTerminalCopyEvent(selection, event.clipboardData); + if (result.preventDefault) event.preventDefault(); + if (result.claimWriteFallback) { + // The native event actually wrote the selection; drop the in-flight + // writeText so a late resolution cannot clobber a later user copy. + this.copyShortcutToken += 1; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + } + }; + + private readonly onPaste = (event: ClipboardEvent) => { + // Always suppress the browser's default insertion: content the textarea + // would receive (for example an html-only clipboard converted to text) + // leaks through onInput without bracketed-paste encoding. + event.preventDefault(); + const data = event.clipboardData?.getData("text/plain") ?? ""; + if (data.length === 0) return; + // The native paste won the race with actual text; a pending clipboard read + // must not double. An empty native paste leaves the read as the only path. + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(data)); + }; + + private readonly onCompositionStart = () => { + this.clearPrimedCopy(); + this.clearCompositionInputSuppression(); + this.composing = true; + }; + + private readonly onCompositionEnd = (event: CompositionEvent) => { + this.composing = false; + const data = this.input.value || event.data; + if (data.length > 0) this.options.onData(data); + this.input.value = ""; + this.compositionInputToSuppress = data; + this.compositionSuppressionTimer = window.setTimeout(() => { + this.compositionInputToSuppress = null; + this.compositionSuppressionTimer = null; + }, 100); + }; + + private readonly onInput = (event: Event) => { + const inputEvent = event as InputEvent; + if (this.composing || inputEvent.isComposing) return; + const data = this.input.value || inputEvent.data || ""; + if (data === this.compositionInputToSuppress && isTerminalCompositionCommitInput(inputEvent)) { + this.clearCompositionInputSuppression(); + this.input.value = ""; + return; + } + this.clearCompositionInputSuppression(); + if (data.length > 0) this.options.onData(data); + this.input.value = ""; + }; + + private clearCompositionInputSuppression(): void { + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + this.compositionSuppressionTimer = null; + } + this.compositionInputToSuppress = null; + } + + private readonly onPointerDown = (event: PointerEvent) => { + this.focus(); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = ghosttyMouseButton(event.button); + if (button === null) return; + event.preventDefault(); + event.stopPropagation(); + this.clearHoveredLink("default"); + this.mouseReportingPointerId = event.pointerId; + this.mouseReportingButton = button; + this.sendMouse("press", button, event); + this.canvas.setPointerCapture(event.pointerId); + return; + } + if (event.button !== 0) return; + if (isTerminalLinkPointerGesture(event)) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = event.pointerId; + this.canvas.setPointerCapture(event.pointerId); + return; + } + this.clearHoveredLink(); + const cell = this.cellAt(event.clientX, event.clientY); + this.selectionMoved = false; + this.selectionClickSequence = advanceTerminalSelectionClickSequence( + this.selectionClickSequence, + event, + ); + const clickCount = this.selectionClickSequence.count; + this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; + const range = + this.selectionMode === "line" + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === "word" + ? this.core.selectWord(cell.x, cell.y) + : null; + if (range) { + this.selectionBase = range.screen; + this.selectionEnd = range.viewport.end; + this.selectionAnchorScreen = range.screen.start; + this.selectionEndScreen = range.screen.end; + this.options.onSelectionChange(); + } else { + this.selectionMode = "cell"; + this.selectionBase = null; + this.selectionEnd = cell; + const screen = this.core.viewportPointToScreen(cell.x, cell.y); + this.selectionAnchorScreen = screen; + this.selectionEndScreen = screen; + if (screen) { + this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 }); + } else { + this.core.setSelection(cell, cell); + } + } + this.forceFullRender = true; + this.canvas.setPointerCapture(event.pointerId); + this.requestRender(); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (this.linkActivationPointerId === event.pointerId) return; + // Hover motion is only reportable in any-event tracking (DEC 1003); normal and + // button-event tracking never report motion without a captured pressed button. + const anyEventTracking = this.synchronizeMouseTrackingState(); + if ( + this.mouseReportingPointerId === event.pointerId || + shouldReportTerminalMouse(anyEventTracking, event) + ) { + event.preventDefault(); + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + // A drag whose press was already sent to the terminal application cannot + // turn into link activation midway through, so link feedback would lie. + this.setHoveredLink(null); + this.canvas.style.cursor = "default"; + this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); + return; + } + this.lastMouseMotionData = ""; + if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { + this.updateHoverCursor(event); + return; + } + this.clearHoveredLink(); + this.selectionPointer = { x: event.clientX, y: event.clientY }; + const bounds = this.canvas.getBoundingClientRect(); + this.setSelectionAutoscroll( + event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, + ); + const cell = this.cellAt(event.clientX, event.clientY); + if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; + this.extendSelectionTo(event.clientX, event.clientY); + }; + + private extendSelectionTo(clientX: number, clientY: number): void { + const anchorScreen = this.selectionAnchorScreen; + if (anchorScreen === null) return; + const cell = this.cellAt(clientX, clientY); + this.selectionMoved = true; + this.selectionEnd = cell; + const range = + this.selectionMode === "line" + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === "word" + ? this.core.selectWord(cell.x, cell.y) + : null; + const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y); + if (cellScreen === null) return; + const base = this.selectionBase; + const beforeBase = + base !== null && + (cellScreen.y < base.start.y || + (cellScreen.y === base.start.y && cellScreen.x < base.start.x)); + const anchor = base === null ? anchorScreen : beforeBase ? base.end : base.start; + const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end; + this.selectionAnchorScreen = anchor; + this.selectionEndScreen = end; + this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }); + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + } + + private setSelectionAutoscroll(delta: number): void { + this.selectionScrollDelta = delta; + if (delta === 0) { + if (this.selectionScrollTimer !== null) { + window.clearInterval(this.selectionScrollTimer); + this.selectionScrollTimer = null; + } + return; + } + if (this.selectionScrollTimer !== null) return; + // Dragging past the edge scrolls the viewport and keeps extending the + // selection into the newly revealed rows, like xterm's drag scroller. + this.selectionScrollTimer = window.setInterval(() => { + if (this.disposed || this.selectionScrollDelta === 0) return; + this.scrollViewport(this.selectionScrollDelta); + const pointer = this.selectionPointer; + if (pointer) this.extendSelectionTo(pointer.x, pointer.y); + }, 80); + } + + private updateHoverCursor(event: PointerEvent): void { + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } + + private updateLinkModifier(event: Pick): void { + const active = isTerminalLinkPointerGesture(event); + if (active === this.linkModifierActive) return; + this.linkModifierActive = active; + this.refreshHoveredLink(); + } + + private readonly onPointerLeave = () => { + this.lastMouseMotionData = ""; + this.clearHoveredLink(); + }; + + private clearHoveredLink(cursor = ""): void { + this.hoverPointer = null; + this.setHoveredLink(null); + this.canvas.style.cursor = cursor; + } + + private refreshHoveredLink(): void { + const pointer = this.hoverPointer; + const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; + this.setHoveredLink(link); + } + + private setHoveredLink(link: TerminalLinkWithRange | null): void { + const previous = this.hoveredLink; + const unchanged = + previous?.text === link?.text && + previous?.range.start.x === link?.range.start.x && + previous?.range.start.y === link?.range.start.y && + previous?.range.end.x === link?.range.end.x && + previous?.range.end.y === link?.range.end.y; + this.canvas.style.cursor = link ? "pointer" : ""; + if (unchanged) return; + this.hoveredLink = link; + this.forceFullRender = true; + this.requestRender(); + } + + private readonly onPointerUp = (event: PointerEvent) => { + this.setSelectionAutoscroll(0); + if (this.linkActivationPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type !== "pointercancel") { + const link = this.linkAt(event.clientX, event.clientY); + if (link) this.options.onLinkActivate(link.text, event); + } + return; + } + if (this.mouseReportingPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.sendMouse("release", this.mouseReportingButton, event); + this.mouseReportingPointerId = null; + this.mouseReportingButton = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type === "pointercancel") { + this.clearHoveredLink(); + } else { + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } + return; + } + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.button !== 0) return; + if (!this.selectionMoved && this.selectionMode === "cell") { + this.clearSelection(); + } + this.options.onSelectionChange(); + }; + + private readonly onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = terminalWheelDeltaRows( + event, + this.metrics.height, + this.rows, + this.wheelRemainder, + ); + this.wheelRemainder = delta.remainder; + if (delta.rows === 0) return; + const magnitude = Math.abs(delta.rows); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = delta.rows < 0 ? 4 : 5; + for (let index = 0; index < magnitude; index += 1) { + this.sendMouse("press", button, event); + } + return; + } + if (this.core.isAlternateScreen()) { + // The alternate screen has no scrollback: translate wheel motion into + // arrow keys so full-screen apps like vim and less scroll, matching xterm. + this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); + return; + } + this.scrollViewport(delta.rows); + }; + + private readonly onMouseDown = (event: MouseEvent) => { + if (event.button === 0) event.preventDefault(); + this.focus(); + }; + + private readonly onContextMenu = (event: MouseEvent) => { + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + event.preventDefault(); + return; + } + this.options.onContextMenu?.(event); + }; + + private readonly onScrollbarPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const state = this.readScrollbarState(); + if (state === null) return; + const bounds = this.scrollbar.getBoundingClientRect(); + const geometry = terminalScrollbarGeometry(state, bounds.height); + if (geometry === null) return; + event.preventDefault(); + event.stopPropagation(); + this.scrollbarPointerId = event.pointerId; + this.scrollbarPointerOffset = + event.target === this.scrollbarThumb + ? event.clientY - bounds.top - geometry.thumbTop + : geometry.thumbHeight / 2; + this.scrollbar.setPointerCapture(event.pointerId); + this.scrollbarToPointer(event.clientY, bounds); + }; + + private readonly onScrollbarPointerMove = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId || this.scrollbarState === null) return; + event.preventDefault(); + this.scrollbarToPointer(event.clientY, this.scrollbar.getBoundingClientRect()); + }; + + private readonly onScrollbarPointerUp = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId) return; + event.preventDefault(); + this.scrollbarPointerId = null; + if (this.scrollbar.hasPointerCapture(event.pointerId)) { + this.scrollbar.releasePointerCapture(event.pointerId); + } + }; + + private readonly onScrollbarKeyDown = (event: KeyboardEvent) => { + const state = this.readScrollbarState(); + if (state === null) return; + let delta = 0; + switch (event.key) { + case "ArrowUp": + delta = -1; + break; + case "ArrowDown": + delta = 1; + break; + case "PageUp": + delta = -Math.max(1, state.len); + break; + case "PageDown": + delta = Math.max(1, state.len); + break; + case "Home": + delta = -state.offset; + break; + case "End": + delta = state.total - state.len - state.offset; + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + this.scrollViewport(delta); + }; + + private installEvents(): void { + this.input.addEventListener("keydown", this.onKeyDown); + this.input.addEventListener("keyup", this.onKeyUp); + this.input.addEventListener("focus", this.onFocus); + this.input.addEventListener("blur", this.onBlur); + this.input.addEventListener("input", this.onInput); + this.input.addEventListener("paste", this.onPaste); + this.input.addEventListener("copy", this.onCopyEvent); + this.input.addEventListener("compositionstart", this.onCompositionStart); + this.input.addEventListener("compositionend", this.onCompositionEnd); + this.canvas.addEventListener("pointerdown", this.onPointerDown); + this.canvas.addEventListener("pointermove", this.onPointerMove); + this.canvas.addEventListener("pointerleave", this.onPointerLeave); + this.canvas.addEventListener("pointerup", this.onPointerUp); + this.canvas.addEventListener("pointercancel", this.onPointerUp); + this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); + this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("contextmenu", this.onContextMenu); + this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); + this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); + this.scrollbar.addEventListener("pointerup", this.onScrollbarPointerUp); + this.scrollbar.addEventListener("pointercancel", this.onScrollbarPointerUp); + this.scrollbar.addEventListener("keydown", this.onScrollbarKeyDown); + } + + private removeEvents(): void { + this.input.removeEventListener("keydown", this.onKeyDown); + this.input.removeEventListener("keyup", this.onKeyUp); + this.input.removeEventListener("focus", this.onFocus); + this.input.removeEventListener("blur", this.onBlur); + this.input.removeEventListener("input", this.onInput); + this.input.removeEventListener("paste", this.onPaste); + this.input.removeEventListener("copy", this.onCopyEvent); + this.input.removeEventListener("compositionstart", this.onCompositionStart); + this.input.removeEventListener("compositionend", this.onCompositionEnd); + this.canvas.removeEventListener("pointerdown", this.onPointerDown); + this.canvas.removeEventListener("pointermove", this.onPointerMove); + this.canvas.removeEventListener("pointerleave", this.onPointerLeave); + this.canvas.removeEventListener("pointerup", this.onPointerUp); + this.canvas.removeEventListener("pointercancel", this.onPointerUp); + this.canvas.removeEventListener("wheel", this.onWheel); + this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("contextmenu", this.onContextMenu); + this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); + this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); + this.scrollbar.removeEventListener("pointerup", this.onScrollbarPointerUp); + this.scrollbar.removeEventListener("pointercancel", this.onScrollbarPointerUp); + this.scrollbar.removeEventListener("keydown", this.onScrollbarKeyDown); + } + + private scrollViewport(deltaRows: number): void { + let delta = Math.trunc(deltaRows); + const state = this.readScrollbarState(); + if (state !== null) { + const maxOffset = Math.max(0, state.total - state.len); + const offset = Math.max(0, Math.min(state.offset + delta, maxOffset)); + delta = offset - state.offset; + this.scrollbarState = { ...state, offset }; + } + if (delta === 0) return; + this.core.scroll(delta); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + private scrollbarToPointer(clientY: number, bounds: DOMRect): void { + const state = this.scrollbarState; + if (state === null) return; + const offset = terminalScrollbarOffsetAtPointer( + state, + bounds.height, + clientY - bounds.top, + this.scrollbarPointerOffset, + ); + this.scrollViewport(offset - state.offset); + } + + private updateScrollbar(): void { + const state = this.readScrollbarState(); + const geometry = + state === null + ? null + : terminalScrollbarGeometry( + state, + Math.max(0, this.mount.clientHeight - CONTENT_PADDING * 2), + ); + this.scrollbar.hidden = geometry === null; + if (state === null || geometry === null) return; + this.scrollbar.setAttribute("aria-valuemin", "0"); + this.scrollbar.setAttribute("aria-valuemax", String(geometry.maxOffset)); + this.scrollbar.setAttribute( + "aria-valuenow", + String(Math.max(0, Math.min(state.offset, geometry.maxOffset))), + ); + this.scrollbarThumb.style.height = `${geometry.thumbHeight}px`; + this.scrollbarThumb.style.transform = `translateY(${geometry.thumbTop}px)`; + } + + private readScrollbarState(): GhosttyScrollbar | null { + const state = this.core.scrollbarState(); + this.scrollbarState = state; + return state; + } + + private requestRender(): void { + if (this.disposed || this.frame !== 0) return; + this.frame = window.requestAnimationFrame(() => { + this.frame = 0; + this.renderFrame(); + }); + } + + private renderFrame(): void { + if (this.disposed) return; + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + this.snapshot = this.core.snapshot(); + // A cursor that is not blinking right now must be drawn, never caught in an + // off phase left behind by a blink that has since been turned off. + if (!this.blinkEnabled()) this.cursorOn = true; + // The origin only moves together with a forced full repaint: partial + // dirty-row redraws must never composite rows at a shifted origin over + // rows painted at the previous one. Bottom anchoring starts once + // scrollback exists, i.e. when the prompt actually lives at the bottom. + const scrollState = this.readScrollbarState(); + const anchorBottom = scrollState !== null && scrollState.total > scrollState.len; + const nextOriginY = terminalContentOriginY( + this.mountHeight, + CONTENT_PADDING, + this.rows, + this.metrics.height, + anchorBottom, + ); + if (nextOriginY !== this.originY) { + this.originY = nextOriginY; + this.forceFullRender = true; + } + this.refreshHoveredLink(); + renderGhosttySnapshot({ + context: this.context, + snapshot: this.snapshot, + metrics: this.metrics, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + padding: CONTENT_PADDING, + originY: this.originY, + forceFull: this.forceFullRender, + cursorOn: this.cursorOn, + previousCursorY: this.renderedCursorY, + focused: this.focused, + hoveredLinkRange: this.hoveredLink?.range ?? null, + ...(this.theme.selectionBackground !== undefined + ? { selectionBackground: this.theme.selectionBackground } + : {}), + }); + this.positionInput(); + this.renderedCursorY = + this.cursorOn && this.snapshot.cursorVisible && this.snapshot.cursorY >= 0 + ? this.snapshot.cursorY + : null; + if (this.scrollbarDirty) { + this.scrollbarDirty = false; + this.updateScrollbar(); + } + this.forceFullRender = false; + this.scheduleCursorBlink(); + } + + private scheduleCursorBlink(): void { + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + if (!this.blinkEnabled()) return; + this.cursorTimer = window.setTimeout(() => { + this.cursorTimer = null; + this.cursorOn = !this.cursorOn; + this.requestRender(); + }, CURSOR_BLINK_INTERVAL_MS); + } + + private blinkEnabled(): boolean { + const snapshot = this.snapshot; + if (!snapshot) return false; + return shouldBlinkTerminalCursor({ + focused: this.focused, + cursorBlinking: snapshot.cursorBlinking, + cursorVisible: snapshot.cursorVisible, + reducedMotion: this.reducedMotionMedia?.matches ?? false, + }); + } + + private positionInput(): void { + const snapshot = this.snapshot; + if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { + return; + } + // The IME candidate window anchors to the textarea, so it must follow the + // terminal cursor for composition to appear where the user is typing. + const left = CONTENT_PADDING + snapshot.cursorX * this.metrics.width; + const top = this.originY + snapshot.cursorY * this.metrics.height; + if (left === this.inputLeft && top === this.inputTop) return; + this.inputLeft = left; + this.inputTop = top; + this.input.style.left = `${left}px`; + this.input.style.top = `${top}px`; + this.input.style.height = `${this.metrics.height}px`; + } + + private cellAt(clientX: number, clientY: number): { x: number; y: number } { + const bounds = this.canvas.getBoundingClientRect(); + return { + x: Math.max( + 0, + Math.min( + this.cols - 1, + Math.floor((clientX - bounds.left - CONTENT_PADDING) / this.metrics.width), + ), + ), + y: Math.max( + 0, + Math.min( + this.rows - 1, + Math.floor((clientY - bounds.top - this.originY) / this.metrics.height), + ), + ), + }; + } + + private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { + if (!this.snapshot) return null; + const cell = terminalGridCellAt({ + bounds: this.canvas.getBoundingClientRect(), + clientX, + clientY, + cols: this.cols, + rows: this.rows, + metrics: this.metrics, + padding: CONTENT_PADDING, + originY: this.originY, + }); + if (!cell) return null; + const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); + if (explicitHyperlink) { + const start = { ...cell }; + const end = { ...cell }; + while (true) { + const previous = + start.x > 0 + ? { x: start.x - 1, y: start.y } + : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation + ? { x: this.cols - 1, y: start.y - 1 } + : null; + if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break; + start.x = previous.x; + start.y = previous.y; + } + while (true) { + const next = + end.x + 1 < this.cols + ? { x: end.x + 1, y: end.y } + : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext + ? { x: 0, y: end.y + 1 } + : null; + if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break; + end.x = next.x; + end.y = next.y; + } + return { + text: explicitHyperlink, + range: { start, end }, + }; + } + return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); + } + + private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void { + const bounds = this.canvas.getBoundingClientRect(); + const data = this.core.encodeMouse({ + action, + button, + mods: + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0), + x: Math.max(0, event.clientX - bounds.left), + y: Math.max(0, event.clientY - bounds.top), + screenWidth: bounds.width, + screenHeight: bounds.height, + cellWidth: this.metrics.width, + cellHeight: this.metrics.height, + paddingLeft: CONTENT_PADDING, + paddingRight: CONTENT_PADDING, + paddingTop: this.originY, + paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), + anyButtonPressed: event.buttons !== 0, + }); + const resolution = resolveTerminalMouseData(action, data, this.lastMouseMotionData); + this.lastMouseMotionData = resolution.nextMotionData; + if (resolution.send) this.options.onData(data); + } + + private synchronizeMouseTrackingState(): boolean { + // Output writes can toggle DEC 1003 without moving the pointer. Keep the + // previous mode so the next same-cell motion starts a fresh tracking session. + const tracking = this.core.isMouseAnyEventTracking(); + const state = resolveTerminalMouseTrackingState( + this.mouseAnyEventTracking, + tracking, + this.lastMouseMotionData, + ); + this.mouseAnyEventTracking = state.tracking; + this.lastMouseMotionData = state.motionData; + return tracking; + } + + private buttonFromButtons(buttons: number): number | null { + if ((buttons & 1) !== 0) return 1; + if ((buttons & 4) !== 0) return 3; + if ((buttons & 2) !== 0) return 2; + if ((buttons & 8) !== 0) return 4; + if ((buttons & 16) !== 0) return 5; + return null; + } +} diff --git a/renderer/lib/ghostty-terminal/terminal-links.ts b/renderer/lib/ghostty-terminal/terminal-links.ts new file mode 100644 index 00000000..977a6f45 --- /dev/null +++ b/renderer/lib/ghostty-terminal/terminal-links.ts @@ -0,0 +1,290 @@ +import { isMacPlatform } from "./host-platform"; + +export type TerminalLinkKind = "url" | "path"; + +export interface TerminalLinkMatch { + kind: TerminalLinkKind; + text: string; + start: number; + end: number; +} + +export interface TerminalLinkBufferPosition { + x: number; + y: number; +} + +export interface TerminalLinkBufferRange { + start: TerminalLinkBufferPosition; + end: TerminalLinkBufferPosition; +} + +export interface TerminalBufferLineLike { + readonly isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +export interface WrappedTerminalLinkLineSegment { + bufferLineNumber: number; + text: string; + startIndex: number; + endIndex: number; +} + +export interface WrappedTerminalLinkLine { + text: string; + segments: ReadonlyArray; +} + +const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu; +const FILE_PATH_PATTERN = + /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; + +function trimClosingDelimiters(value: string): string { + let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); + if (output.length === 0) return output; + + const trimUnbalanced = (open: string, close: string) => { + while (output.endsWith(close)) { + const opens = output.split(open).length - 1; + const closes = output.split(close).length - 1; + if (opens >= closes) return; + output = output.slice(0, -1); + } + }; + + trimUnbalanced("(", ")"); + trimUnbalanced("[", "]"); + trimUnbalanced("{", "}"); + return output; +} + +function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean { + return a.start < b.end && b.start < a.end; +} + +function collectMatches( + line: string, + kind: TerminalLinkKind, + pattern: RegExp, + existing: TerminalLinkMatch[], +): TerminalLinkMatch[] { + const matches: TerminalLinkMatch[] = []; + pattern.lastIndex = 0; + + for (const rawMatch of line.matchAll(pattern)) { + const raw = rawMatch[0]; + const start = rawMatch.index ?? -1; + if (start < 0 || raw.length === 0) continue; + + const trimmed = trimClosingDelimiters(raw); + if (trimmed.length === 0) continue; + if (kind === "path" && isTerminalUrl(trimmed)) continue; + + const candidate: TerminalLinkMatch = { + kind, + text: trimmed, + start, + end: start + trimmed.length, + }; + + const collides = [...existing, ...matches].some((other) => overlaps(candidate, other)); + if (collides) continue; + + matches.push(candidate); + } + + return matches; +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || isWindowsAbsolutePath(value); +} + +function isWindowsPathStyle(value: string): boolean { + return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); +} + +function joinPath(base: string, next: string, separator: "/" | "\\"): string { + const cleanBase = base.replace(/[\\/]+$/, ""); + if (separator === "\\") { + return `${cleanBase}\\${next.replace(/\//gu, "\\")}`; + } + return `${cleanBase}/${next.replace(/^\/+/, "")}`; +} + +function inferHomeFromCwd(cwd: string): string | undefined { + const posixUser = cwd.match(/^\/Users\/([^/]+)/); + if (posixUser?.[1]) { + return `/Users/${posixUser[1]}`; + } + + const posixHome = cwd.match(/^\/home\/([^/]+)/); + if (posixHome?.[1]) { + return `/home/${posixHome[1]}`; + } + + const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/); + if (windowsUser?.[1]) { + return windowsUser[1]; + } + + return undefined; +} + +export function splitPathAndPosition(value: string): { + path: string; + line: string | undefined; + column: string | undefined; +} { + let path = value; + let column: string | undefined; + let line: string | undefined; + + const columnMatch = path.match(/:(\d+)$/); + if (!columnMatch?.[1]) { + return { path, line: undefined, column: undefined }; + } + + column = columnMatch[1]; + path = path.slice(0, -columnMatch[0].length); + + const lineMatch = path.match(/:(\d+)$/); + if (lineMatch?.[1]) { + line = lineMatch[1]; + path = path.slice(0, -lineMatch[0].length); + } else { + line = column; + column = undefined; + } + + return { path, line, column }; +} + +export function extractTerminalLinks(line: string): TerminalLinkMatch[] { + const urlMatches = collectMatches(line, "url", URL_PATTERN, []); + const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); + return [...urlMatches, ...pathMatches].sort((a, b) => a.start - b.start); +} + +export function isTerminalUrl(value: string): boolean { + return /^https?:\/\//iu.test(value); +} + +export function collectWrappedTerminalLinkLine( + bufferLineNumber: number, + getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, +): WrappedTerminalLinkLine | null { + const anchorLine = getLine(bufferLineNumber - 1); + if (!anchorLine) return null; + + let startBufferLineNumber = bufferLineNumber; + let startLine = anchorLine; + + while (startBufferLineNumber > 1 && startLine.isWrapped) { + const previousLine = getLine(startBufferLineNumber - 2); + if (!previousLine) return null; + startBufferLineNumber -= 1; + startLine = previousLine; + } + + const segments: WrappedTerminalLinkLineSegment[] = []; + let nextStartIndex = 0; + let currentBufferLineNumber = startBufferLineNumber; + + while (true) { + const currentLine = getLine(currentBufferLineNumber - 1); + if (!currentLine) break; + + const nextLine = getLine(currentBufferLineNumber); + const hasWrappedContinuation = nextLine?.isWrapped === true; + const text = currentLine.translateToString(!hasWrappedContinuation); + + segments.push({ + bufferLineNumber: currentBufferLineNumber, + text, + startIndex: nextStartIndex, + endIndex: nextStartIndex + text.length, + }); + nextStartIndex += text.length; + + if (!hasWrappedContinuation) break; + currentBufferLineNumber += 1; + } + + return { + text: segments.map((segment) => segment.text).join(""), + segments, + }; +} + +function resolveCharacterPosition( + segments: ReadonlyArray, + characterIndex: number, +): TerminalLinkBufferPosition { + for (const segment of segments) { + if (characterIndex < segment.endIndex) { + return { + x: characterIndex - segment.startIndex + 1, + y: segment.bufferLineNumber, + }; + } + } + + const lastSegment = segments[segments.length - 1]; + return { + x: Math.max(lastSegment?.text.length ?? 0, 1), + y: lastSegment?.bufferLineNumber ?? 1, + }; +} + +export function resolveWrappedTerminalLinkRange( + wrappedLine: WrappedTerminalLinkLine, + match: Pick, +): TerminalLinkBufferRange { + return { + start: resolveCharacterPosition(wrappedLine.segments, match.start), + end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), + }; +} + +export function wrappedTerminalLinkRangeIntersectsBufferLine( + range: TerminalLinkBufferRange, + bufferLineNumber: number, +): boolean { + return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; +} + +export function isTerminalLinkActivation( + event: Pick, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + if (platform.length === 0) return false; + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function resolvePathLinkTarget(rawPath: string, cwd: string): string { + const { path, line, column } = splitPathAndPosition(rawPath); + + let resolvedPath = path; + if (path.startsWith("~/")) { + const home = inferHomeFromCwd(cwd); + if (home) { + const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; + resolvedPath = joinPath(home, path.slice(2), separator); + } + } else if (!isAbsolutePath(path)) { + const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; + resolvedPath = joinPath(cwd, path, separator); + } + + if (!line) return resolvedPath; + return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; +} diff --git a/renderer/lib/ghostty-terminal/theme.ts b/renderer/lib/ghostty-terminal/theme.ts new file mode 100644 index 00000000..4ab4396d --- /dev/null +++ b/renderer/lib/ghostty-terminal/theme.ts @@ -0,0 +1,35 @@ +import type { GhosttyColor, GhosttyTheme } from "./core"; + +export function parseCssColor(value: string, fallback: number): number { + const hex = value.trim(); + const match = /^#([\da-f]{6})$/iu.exec(hex); + if (match?.[1]) return Number.parseInt(match[1], 16); + const rgb = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/iu.exec(hex); + if (rgb) { + return ((Number(rgb[1]) & 255) << 16) | ((Number(rgb[2]) & 255) << 8) | (Number(rgb[3]) & 255); + } + return fallback; +} + +export function ghosttyColorFromCss(value: string, fallback: number): GhosttyColor { + const packed = parseCssColor(value, fallback); + return { + r: (packed >> 16) & 255, + g: (packed >> 8) & 255, + b: packed & 255, + }; +} + +export function ghosttyThemeFromCss(theme: { + foreground: string; + background: string; + cursor: string; + selectionBackground: string; +}): GhosttyTheme { + return { + foreground: ghosttyColorFromCss(theme.foreground, 0xe6e9ee), + background: ghosttyColorFromCss(theme.background, 0x1d232d), + cursor: ghosttyColorFromCss(theme.cursor, 0x0a84ff), + selectionBackground: theme.selectionBackground, + }; +} diff --git a/renderer/lib/ghostty-terminal/vendor/ghostty-vt.wasm b/renderer/lib/ghostty-terminal/vendor/ghostty-vt.wasm new file mode 100644 index 00000000..a8e2405a Binary files /dev/null and b/renderer/lib/ghostty-terminal/vendor/ghostty-vt.wasm differ diff --git a/renderer/lib/ghostty-terminal/vendor/ghostty-write-pty.wasm b/renderer/lib/ghostty-terminal/vendor/ghostty-write-pty.wasm new file mode 100644 index 00000000..66835717 Binary files /dev/null and b/renderer/lib/ghostty-terminal/vendor/ghostty-write-pty.wasm differ diff --git a/renderer/lib/ghostty-terminal/wasm-assets.ts b/renderer/lib/ghostty-terminal/wasm-assets.ts new file mode 100644 index 00000000..f7106bbc --- /dev/null +++ b/renderer/lib/ghostty-terminal/wasm-assets.ts @@ -0,0 +1,10 @@ +/** + * Compile-time wasm URLs for Vite. Keep these literals in a module that + * Node tests never load: `tsx`/esbuild rewrites `new URL("./*.wasm", + * import.meta.url)` and the `?no-inline` query made the write-pty file + * unresolvable during `npm test`. + * + * The renderer loads this file through a dynamic import gated on `window`. + */ +export const VT_WASM_URL = new URL("./vendor/ghostty-vt.wasm", import.meta.url); +export const WRITE_PTY_WASM_URL = new URL("./vendor/ghostty-write-pty.wasm", import.meta.url); diff --git a/renderer/styles.css b/renderer/styles.css index 5b3dade6..2458a2eb 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1113,12 +1113,14 @@ textarea { transition: none; } -.terminal-drawer .xterm { +.terminal-drawer .ghostty-screen { + position: relative; height: 100%; } -.terminal-drawer .xterm-viewport { - background-color: transparent !important; +.terminal-drawer .ghostty-screen canvas { + height: 100%; + width: 100%; } @keyframes aiden-terminal-drawer-in { diff --git a/scripts/verify-ghostty-terminal-assets.mjs b/scripts/verify-ghostty-terminal-assets.mjs new file mode 100644 index 00000000..2db96796 --- /dev/null +++ b/scripts/verify-ghostty-terminal-assets.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +const rendererDirectory = resolve(import.meta.dirname, "../build/renderer"); + +async function listFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }), + ); + return nested.flat(); +} + +const files = await listFiles(rendererDirectory); + +function findAsset(pattern, label) { + const match = files.find((path) => pattern.test(path)); + assert.ok(match, `${label} must be emitted as a same-origin production asset.`); + return match; +} + +findAsset(/ghostty-vt[^/]*\.wasm$/u, "libghostty-vt"); +findAsset(/ghostty-write-pty[^/]*\.wasm$/u, "libghostty-vt PTY trampoline"); +findAsset(/SymbolsNerdFontMono-Regular[^/]*\.woff2$/u, "terminal Symbols Nerd Font"); diff --git a/tests/e2e/terminal.spec.ts b/tests/e2e/terminal.spec.ts index ac613797..2ae80890 100644 --- a/tests/e2e/terminal.spec.ts +++ b/tests/e2e/terminal.spec.ts @@ -21,7 +21,8 @@ test("workspace terminal opens a real PTY, runs a shell command, and persists ou const hideTerminal = drawer.getByRole("button", { name: "Hide terminal" }); await expect(hideTerminal).toBeVisible(); - await drawer.locator(".xterm-screen").click(); + await expect(drawer.locator(".ghostty-screen")).toBeVisible(); + await drawer.locator(".ghostty-screen").click(); await page.keyboard.type("echo $((314159+271828)); pwd"); await page.keyboard.press("Enter"); diff --git a/vite.config.ts b/vite.config.ts index 367f5a7b..fc0030d7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig(({ command }) => { return { plugins: [react(), tailwindcss()], base: "./", + assetsInclude: ["**/*.wasm"], define: { __APP_DISPLAY_NAME__: JSON.stringify(appDisplayName) }, resolve: { alias: { @@ -19,6 +20,10 @@ export default defineConfig(({ command }) => { outDir: "build/renderer", emptyOutDir: true, sourcemap: true, + // The write-pty trampoline is 112 bytes. Vite's default 4kb inline + // limit would emit it as a data: URL; Chromium then fetches that URL + // under CSP connect-src 'self', which fails. Keep wasm as real files. + assetsInlineLimit: (filePath) => (filePath.endsWith(".wasm") ? false : undefined), rollupOptions: { input: { "main-window": resolve(import.meta.dirname, "main-window.html"),