diff --git a/README.md b/README.md index 8d6b35b1..79fe45e0 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Special thanks to: - [OpenCode](https://opencode.ai) for the API and open-source architecture OpenChamber builds on - [Pierre](https://pierrejs-docs.vercel.app/) for the diff viewer and syntax highlighting -- [Ghostty-web](https://github.com/coder/ghostty-web) for its Ghostty web renderer +- The [T3 Code](https://github.com/pingdotgg/t3code) team for their browser adapter for [libghostty-vt](https://github.com/ghostty-org/ghostty), which our terminal is built on - [Yulia Ivashko](https://github.com/yulia-ivashko), who built the firework celebration that plays on every successful push - Everyone who contributed code, reported bugs, or shared ideas diff --git a/bun.lock b/bun.lock index 7cf18484..e2b18c35 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", @@ -185,7 +184,6 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", @@ -323,7 +321,6 @@ "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "globals": "^16.3.0", "next-themes": "^0.4.6", "nodemon": "^3.1.7", @@ -2122,8 +2119,6 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@0.4.0-next.20.g1858a59", "", {}, "sha512-NXA9H3IJlx+DGJukXbOPQWFkigYdAatTqkoIvM8tvhfbaYoDf3gGKxXLLyXtLYOBt5qzGdEZa294TU36gULjVg=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], diff --git a/package.json b/package.json index 94e6d6f1..29f6978a 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", diff --git a/packages/ui/package.json b/packages/ui/package.json index 364a9f83..76561272 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "tsc --noEmit --watch", "build": "tsc --noEmit", + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "type-check": "tsc --noEmit", "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js", "test": "node ../../scripts/run-isolated-tests.mjs src" @@ -61,7 +62,6 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", diff --git a/packages/ui/scripts/build-libghostty-wasm.sh b/packages/ui/scripts/build-libghostty-wasm.sh new file mode 100755 index 00000000..15f15b91 --- /dev/null +++ b/packages/ui/scripts/build-libghostty-wasm.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# Rebuilds the vendored libghostty-vt WebAssembly artifact from the Ghostty +# revision pinned in src/lib/ghostty/vendor/VERSION, plus the PTY write +# trampoline whose bytes are embedded in src/lib/ghostty/runtime.ts. +# +# Usage: bun run --cwd packages/ui build:ghostty-wasm +# +# The build is reproducible: the same revision and Zig version produce a +# byte-identical ghostty-vt.wasm. Bump VERSION, run this script, and commit the +# new artifact together with any ABI changes in core.ts. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +GHOSTTY_DIR="${UI_DIR}/src/lib/ghostty" +VENDOR_DIR="${GHOSTTY_DIR}/vendor" + +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${VENDOR_DIR}/VERSION")" +CACHE_DIR="${OPENCHAMBER_GHOSTTY_CACHE:-${HOME}/.cache/openchamber-ghostty}" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${CACHE_DIR}/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" + +log() { + printf '[libghostty-vt-wasm] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch zig_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture: ${host_arch}" ;; + esac + + zig_dir="${CACHE_DIR}/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${zig_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${zig_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${zig_dir}" +} + +# Zig 0.15.2 links its build runner against the macOS SDK's libSystem stub. +# SDKs shipped with Xcode 26.x and later list only `arm64e-macos` in that +# stub, which Zig rejects for an arm64 host, so every native link fails with +# "undefined symbol: _abort". The wasm target itself is unaffected. Work around +# it with a minimal SDK root whose stubs also declare `arm64-macos`, and an +# xcrun shim so Zig's SDK lookup lands on it. +ensure_macos_sdk_shim() { + [[ "$(uname -s)" == "Darwin" ]] || return 0 + local sdk_path + sdk_path="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" + [[ -n "${sdk_path}" ]] || die "xcrun could not locate a macOS SDK; install the Command Line Tools" + if grep -q "arm64-macos" "${sdk_path}/usr/lib/libSystem.tbd" 2>/dev/null; then + return 0 + fi + + local shim_root="${CACHE_DIR}/sdk-shim" + local shim_sdk="${shim_root}/MacOSX.sdk" + rm -rf "${shim_root}" + mkdir -p "${shim_sdk}/usr/lib/system" "${shim_root}/bin" + cp "${sdk_path}"/SDKSettings.* "${shim_sdk}/" 2>/dev/null || true + ln -s "${sdk_path}/usr/include" "${shim_sdk}/usr/include" + cp "${sdk_path}"/usr/lib/*.tbd "${shim_sdk}/usr/lib/" + cp "${sdk_path}"/usr/lib/system/*.tbd "${shim_sdk}/usr/lib/system/" + local stub + for stub in "${shim_sdk}"/usr/lib/*.tbd "${shim_sdk}"/usr/lib/system/*.tbd; do + sed -i '' 's/arm64e-macos/arm64-macos, arm64e-macos/g' "${stub}" + done + cat > "${shim_root}/bin/xcrun" </dev/null || echo none)" + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then + log "checking out Ghostty ${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +ensure_zig +ensure_macos_sdk_shim +ensure_ghostty_source + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT + +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" +( + cd "${GHOSTTY_SOURCE_DIR}" + # The pinned revision rides along as semver build metadata so the artifact + # identifies its own provenance through ghostty_build_info(); VERSION stays + # the single source of truth for the pin and the ABI test checks the two agree. + "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall \ + -Dstrip=true \ + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ + -p "${build_root}" +) + +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" +chmod 0644 "${VENDOR_DIR}/ghostty-vt.wasm" +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" + +"${GHOSTTY_ZIG}" build-exe \ + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ + -target wasm32-freestanding \ + -O ReleaseSmall \ + -fno-entry \ + -rdynamic \ + -femit-bin="${build_root}/ghostty-write-pty.wasm" +log "PTY trampoline bytes for runtime.ts (WRITE_PTY_TRAMPOLINE):" +od -An -v -tu1 "${build_root}/ghostty-write-pty.wasm" | tr -s ' \n' ' ' | sed 's/^ //; s/ $//; s/ /, /g' +echo diff --git a/packages/ui/scripts/ghostty-write-pty.zig b/packages/ui/scripts/ghostty-write-pty.zig new file mode 100644 index 00000000..466524fe --- /dev/null +++ b/packages/ui/scripts/ghostty-write-pty.zig @@ -0,0 +1,12 @@ +// Callback trampoline for libghostty-vt's write-PTY option. +// +// libghostty-vt calls the PTY writer through its indirect function table, so +// the JavaScript host cannot pass a closure directly. This 112-byte module +// exports one function whose only job is to forward the call to an import the +// host implements. `build-libghostty-wasm.sh` compiles it and prints the bytes +// that `runtime.ts` embeds, so the browser never fetches it separately. +extern "env" fn openchamber_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; + +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { + openchamber_write_pty(terminal, userdata, data, len); +} diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 2d5b1920..0b7eef5d 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -680,8 +680,8 @@ export const ContextPanel: React.FC = () => { } // Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode). - // ghostty-web listens in the bubble phase; stopping capture here would - // swallow the key before the terminal ever sees it (issue #2644). + // The terminal input listens in the bubble phase; stopping capture here + // would swallow the key before the terminal ever sees it (issue #2644). if (isTerminalEventTarget(event.target)) { return; } diff --git a/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts b/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts index 63bca320..37bbf57a 100644 --- a/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts +++ b/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts @@ -35,7 +35,7 @@ describe('issue #2644: Escape in terminal must not close the context panel', () expect(handler).toContain('event.stopPropagation()'); expect(handler).toContain('handleClose()'); - // Guard must return before preventDefault/stopPropagation so ghostty-web's + // Guard must return before preventDefault/stopPropagation so the terminal input's // bubble-phase keydown listener can forward Escape to the PTY. const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)'); const preventIndex = handler.indexOf('event.preventDefault()'); diff --git a/packages/ui/src/components/terminal/TerminalViewport.test.tsx b/packages/ui/src/components/terminal/TerminalViewport.test.tsx index 7ee8e582..7878b7e8 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.test.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.test.tsx @@ -1,59 +1,58 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; -import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { Window } from 'happy-dom'; +import { I18nProvider } from '@/lib/i18n'; import { useTerminalStore, type TerminalChunk } from '@/stores/useTerminalStore'; +import { TerminalViewport, type TerminalSurface, type TerminalSurfaceFactory } from './TerminalViewport'; + type TerminalEvent = | { type: 'write'; data: string } - | { type: 'reset' } - | { type: 'resize'; cols: number; rows: number }; + | { type: 'reset'; data: string; size?: { cols: number; rows: number } } + | { type: 'visible'; visible: boolean } + | { type: 'dispose' }; const terminalEvents: TerminalEvent[] = []; -class GhosttyTerminalDouble { - public options: { cursorBlink: boolean }; - public cols = 80; - public rows = 24; - - constructor(options: { cursorBlink?: boolean; cols?: number; rows?: number }) { - this.options = { cursorBlink: options.cursorBlink ?? false }; - this.cols = options.cols ?? 80; - this.rows = options.rows ?? 24; - } - - loadAddon() {} - open() {} - onData() { - return { dispose() {} }; - } - write(data: string, callback?: () => void) { +class TerminalSurfaceDouble implements TerminalSurface { + write(data: string) { terminalEvents.push({ type: 'write', data }); - callback?.(); } - resize(cols: number, rows: number) { - this.cols = cols; - this.rows = rows; - terminalEvents.push({ type: 'resize', cols, rows }); + resetAndWrite(data: string, drawnSize?: { readonly cols: number; readonly rows: number }) { + const event: TerminalEvent = { type: 'reset', data }; + if (drawnSize) event.size = { cols: drawnSize.cols, rows: drawnSize.rows }; + terminalEvents.push(event); } - reset() { - terminalEvents.push({ type: 'reset' }); + setTheme() {} + setFont() { + return Promise.resolve(); } + setVisible(visible: boolean) { + terminalEvents.push({ type: 'visible', visible }); + } + fit() { + return true; + } + refresh() {} focus() {} - dispose() {} + getSelection() { + return ''; + } + getSelectionPosition() { + return null; + } + scrollLines() {} + selectWordAt() { + return false; + } + extendSelectionTo() {} + dispose() { + terminalEvents.push({ type: 'dispose' }); + } } -class FitAddonDouble { - fit() {} -} - -mock.module('ghostty-web', () => ({ - Ghostty: { load: async () => ({}) }, - Terminal: GhosttyTerminalDouble, - FitAddon: FitAddonDouble, -})); - -const { TerminalViewport } = await import('./TerminalViewport'); +const createSurface: TerminalSurfaceFactory = () => Promise.resolve(new TerminalSurfaceDouble()); const theme = { background: '#000000', @@ -80,19 +79,16 @@ const theme = { brightWhite: '#ffffff', } as const; -const flushGhosttyLoad = async () => { +const flushSurfaceLoad = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); }); }; const TERMINAL_BUFFER_CAP = 512 * 1024; -const replayWriteEvents = (expectedPayloads: string[]) => terminalEvents.filter( - (event): event is { type: 'write'; data: string } => event.type === 'write' && expectedPayloads.includes(event.data), -); - const buildReplacedBufferChunks = (content: string): TerminalChunk[] => { const directory = '/fixture'; useTerminalStore.getState().clearAll(); @@ -103,18 +99,22 @@ const buildReplacedBufferChunks = (content: string): TerminalChunk[] => { return [...useTerminalStore.getState().getBuffer(directory, tabId).chunks]; }; -const renderViewport = (root: Root, chunks: TerminalChunk[]) => act(async () => { +const renderViewport = (root: Root, chunks: TerminalChunk[], isVisible = true) => act(async () => { root.render( - undefined} - onResize={() => undefined} - theme={theme} - monoFont="geist-mono" - fontFamily="Geist Mono" - fontSize={14} - />, + + undefined} + onResize={() => undefined} + theme={theme} + monoFont="system-mono" + fontFamily="Menlo" + fontSize={14} + isVisible={isVisible} + createSurface={createSurface} + /> + , ); }); @@ -135,14 +135,6 @@ describe('TerminalViewport chunk replay integration', () => { Element: windowInstance.Element, Node: windowInstance.Node, Event: windowInstance.Event, - InputEvent: windowInstance.InputEvent, - KeyboardEvent: windowInstance.KeyboardEvent, - MouseEvent: windowInstance.MouseEvent, - FocusEvent: windowInstance.FocusEvent, - ResizeObserver: class { - observe() {} - disconnect() {} - }, requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 1; @@ -150,16 +142,6 @@ describe('TerminalViewport chunk replay integration', () => { cancelAnimationFrame: () => undefined, IS_REACT_ACT_ENVIRONMENT: true, }); - Object.defineProperty(windowInstance.document, 'hasFocus', { - configurable: true, - value: () => true, - }); - Object.defineProperty(windowInstance.HTMLElement.prototype, 'getBoundingClientRect', { - configurable: true, - value() { - return { x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 600, width: 800, height: 600 }; - }, - }); host = document.createElement('div'); document.body.appendChild(host); @@ -172,19 +154,20 @@ describe('TerminalViewport chunk replay integration', () => { useTerminalStore.getState().clearAll(); }); - test('would fail if adopted-buffer remount replay split history writes or exceeded the capped buffer payload', async () => { + test('replays adopted history as one reset and keeps the capped buffer payload intact', async () => { const replayChunks: TerminalChunk[] = [ { id: 1, data: 'live-one\n', replayData: 'replay-one\n', byteLength: 9 }, { id: 2, data: 'live-two\n', replayData: 'replay-two\n', byteLength: 9 }, { id: 3, data: 'live-three\n', byteLength: 11 }, ]; - const replayPayload = 'replay-one\nreplay-two\nlive-three\n'; await renderViewport(root, replayChunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); - expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(0); - expect(replayWriteEvents([replayPayload])).toEqual([{ type: 'write', data: replayPayload }]); + expect(terminalEvents.filter((event) => event.type === 'reset' || event.type === 'write')).toEqual([ + { type: 'reset', data: 'replay-one\n' }, + { type: 'write', data: 'replay-two\nlive-three\n' }, + ]); await act(async () => root.unmount()); host.remove(); @@ -197,13 +180,13 @@ describe('TerminalViewport chunk replay integration', () => { const oversizedPayload = oversizedReplayChunks.map((chunk) => chunk.data).join(''); await renderViewport(root, oversizedReplayChunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); - expect(replayWriteEvents([oversizedPayload])).toEqual([{ type: 'write', data: oversizedPayload }]); + expect(terminalEvents.filter((event) => event.type === 'reset')).toEqual([{ type: 'reset', data: oversizedPayload }]); expect(new TextEncoder().encode(oversizedPayload).byteLength).toBeLessThanOrEqual(TERMINAL_BUFFER_CAP); }); - test('would fail if authoritative replacement replay reset twice or re-streamed replacement history chunk-by-chunk', async () => { + test('appends live chunks and replaces history with a single reset', async () => { const initialChunks: TerminalChunk[] = [ { id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 }, ]; @@ -215,10 +198,9 @@ describe('TerminalViewport chunk replay integration', () => { { id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 }, { id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 }, ]; - const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n'; await renderViewport(root, initialChunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); terminalEvents.length = 0; await renderViewport(root, appendedChunks); @@ -226,66 +208,28 @@ describe('TerminalViewport chunk replay integration', () => { terminalEvents.length = 0; await renderViewport(root, replacementChunks); - expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1); - expect(replayWriteEvents([replacementReplayPayload])).toEqual([{ type: 'write', data: replacementReplayPayload }]); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-1\n')).toBe(false); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-2\n')).toBe(false); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-1\n')).toBe(false); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-2\n')).toBe(false); - }); - - test('would fail if a live append after replacement replay duplicated history or lost the new chunk ordering', async () => { - const initialChunks: TerminalChunk[] = [ - { id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 }, - ]; - const replacementChunks: TerminalChunk[] = [ - { id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 }, - { id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 }, - ]; - const resumedChunks: TerminalChunk[] = [ - ...replacementChunks, - { id: 5, data: 'tail-live\n', replayData: 'tail-replay\n', byteLength: 10 }, - ]; - const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n'; - - await renderViewport(root, initialChunks); - await flushGhosttyLoad(); + expect(terminalEvents).toEqual([ + { type: 'reset', data: 'history-replay-1\n' }, + { type: 'write', data: 'history-replay-2\n' }, + ]); terminalEvents.length = 0; - await renderViewport(root, replacementChunks); - await renderViewport(root, resumedChunks); - - expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1); - expect(replayWriteEvents([replacementReplayPayload, 'tail-live\n'])).toEqual([ - { type: 'write', data: replacementReplayPayload }, - { type: 'write', data: 'tail-live\n' }, - ]); - expect(terminalEvents.filter((event) => event.type === 'write' && event.data === replacementReplayPayload)).toHaveLength(1); - expect(terminalEvents.filter((event) => event.type === 'write' && event.data === 'tail-live\n')).toHaveLength(1); + await renderViewport(root, [...replacementChunks, { id: 5, data: 'tail-live\n', replayData: 'tail-replay\n', byteLength: 10 }]); + expect(terminalEvents).toEqual([{ type: 'write', data: 'tail-live\n' }]); }); - test('would fail if snapshot history drawn for another PTY size were replayed at the fitted size', async () => { - // A zsh prompt drawn for a 94-column PTY: the `%` end-of-line mark plus - // padding fills exactly one 94-column row. Written into an 80-column - // emulator it wraps and the mark survives as a stray fragment. - const history = `%${' '.repeat(93)}\r \r~ ❯ `; + test('passes the PTY size a snapshot was drawn for so the surface replays at that size', async () => { + const history = '[7m%[0m' + ' '.repeat(93) + '\r \r[J~ ❯ '; const chunks: TerminalChunk[] = [ { id: 1, data: history, byteLength: history.length, size: { cols: 94, rows: 56 } }, { id: 2, data: 'live\n', byteLength: 5 }, ]; await renderViewport(root, chunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); - // Default-background resets inside the history are rewritten before the - // write, so identify the history write by the prompt it carries. - const relevant = terminalEvents - .filter((event) => event.type === 'resize' || (event.type === 'write' && (event.data.includes('~ ❯') || event.data === 'live\n'))) - .map((event) => (event.type === 'write' && event.data.includes('~ ❯') ? { type: 'write', data: 'history' } : event)); - expect(relevant).toEqual([ - { type: 'resize', cols: 94, rows: 56 }, - { type: 'write', data: 'history' }, - { type: 'resize', cols: 80, rows: 24 }, + expect(terminalEvents.filter((event) => event.type === 'reset' || event.type === 'write')).toEqual([ + { type: 'reset', data: history, size: { cols: 94, rows: 56 } }, { type: 'write', data: 'live\n' }, ]); @@ -294,15 +238,18 @@ describe('TerminalViewport chunk replay integration', () => { expect(terminalEvents).toEqual([{ type: 'write', data: 'more\n' }]); }); - test('would fail if a snapshot drawn at the fitted size still bounced the emulator through a resize', async () => { - const chunks: TerminalChunk[] = [ - { id: 1, data: 'prompt ❯ ', byteLength: 11, size: { cols: 80, rows: 24 } }, - ]; + test('toggles surface visibility with the prop and disposes on unmount', async () => { + await renderViewport(root, [], false); + await flushSurfaceLoad(); + const hiddenEvents = terminalEvents.filter((event) => event.type === 'visible'); + expect(hiddenEvents.length).toBeGreaterThan(0); + expect(hiddenEvents.every((event) => event.type === 'visible' && !event.visible)).toBe(true); - await renderViewport(root, chunks); - await flushGhosttyLoad(); + await renderViewport(root, [], true); + expect(terminalEvents.at(-1)).toEqual({ type: 'visible', visible: true }); - expect(terminalEvents.filter((event) => event.type === 'resize')).toHaveLength(0); - expect(replayWriteEvents(['prompt ❯ '])).toEqual([{ type: 'write', data: 'prompt ❯ ' }]); + await act(async () => root.unmount()); + expect(terminalEvents.at(-1)).toEqual({ type: 'dispose' }); + root = createRoot(host); }); }); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 436f141c..baf6ed49 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -1,89 +1,88 @@ import React from 'react'; -import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; import { cn } from '@/lib/utils'; import { loadMonoFont } from '@/lib/fontLoader'; import type { MonoFontOption } from '@/lib/fontOptions'; import type { TerminalTheme } from '@/lib/terminalTheme'; -import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; -import { - getGhosttySafeResetSequence, - rewriteGhosttyDefaultBackgroundResets, -} from '@/lib/terminalOutput'; -import { - getTerminalCellFromPoint, - getTerminalWordRange, - type TerminalCellPosition, -} from '@/lib/terminalTouchSelection'; +import { toGhosttyTheme } from '@/lib/terminalTheme'; +import { openExternalUrl } from '@/lib/url'; +import { useI18n } from '@/lib/i18n'; import type { TerminalChunk } from '@/stores/useTerminalStore'; import { selectTerminalChunkReplay } from './terminalChunkReplay'; -// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView -// stays eagerly importable for the bottom dock without pulling the emulator +// The libghostty-vt adapter (WASM VT + canvas renderer) loads on demand so the +// bottom dock can import TerminalView eagerly without pulling the emulator // into the startup graph before a terminal is actually mounted. -type GhosttyModule = typeof import('ghostty-web'); -type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty }; -let ghosttyRuntimePromise: Promise | null = null; -const loadGhostty = (): Promise => - ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({ - module, - ghostty: await module.Ghostty.load(), - })); +type GhosttyTerminalSurface = import('@/lib/ghostty/surface').GhosttyTerminalSurface; +type GhosttyTerminalSurfaceOptions = import('@/lib/ghostty/surface').GhosttyTerminalSurfaceOptions; -// Wait briefly for both the selected mono font and the web entry's deferred -// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block -// opening the terminal, so the renderer starts after the bound and is rebuilt -// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once. -const TERMINAL_FONT_WAIT_MS = 2000; -const loadNerdFonts = (): Promise => - Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined); +/** The subset of the surface the viewport drives; tests inject a double. */ +export type TerminalSurface = Pick< + GhosttyTerminalSurface, + | 'write' + | 'resetAndWrite' + | 'setTheme' + | 'setFont' + | 'setVisible' + | 'fit' + | 'refresh' + | 'focus' + | 'getSelection' + | 'getSelectionPosition' + | 'scrollLines' + | 'selectWordAt' + | 'extendSelectionTo' + | 'dispose' +>; -const waitForTerminalFonts = (font: MonoFontOption) => { - const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined); - const loadedBeforeTimeout = new Promise((resolve) => { - const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS); - void loaded.then(() => { - clearTimeout(timeout); - resolve(true); - }); - }); - return { loaded, loadedBeforeTimeout }; +export type TerminalSurfaceFactory = ( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, +) => Promise; + +const createGhosttySurface: TerminalSurfaceFactory = async (mount, options) => { + const { GhosttyTerminalSurface } = await import('@/lib/ghostty/surface'); + return GhosttyTerminalSurface.create(mount, options); }; +// The selected mono face loads from the app bundle, so this normally resolves +// at once. A stalled fetch must not keep the terminal from opening: after the +// bound the surface measures with whatever faces are available and refits +// when the face arrives (document.fonts "loadingdone"). +const TERMINAL_FONT_WAIT_MS = 2000; +const waitForMonoFont = (font: MonoFontOption): Promise => + new Promise((resolve) => { + const timeout = setTimeout(resolve, TERMINAL_FONT_WAIT_MS); + void loadMonoFont(font).finally(() => { + clearTimeout(timeout); + resolve(); + }); + }); + type TerminalSize = { cols: number; rows: number }; +const CONTENT_PADDING = 4; + const getProvisionalTerminalSize = ( container: HTMLDivElement, fontFamily: string, fontSize: number, ): TerminalSize | null => { - if (typeof window === 'undefined' || typeof document === 'undefined') return null; - - const context = document.createElement('canvas').getContext('2d'); + const context = container.ownerDocument.createElement('canvas').getContext('2d'); if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null; context.font = `${fontSize}px ${fontFamily}`; const metrics = context.measureText('M'); - const cellWidth = Math.ceil(metrics.width); - const cellHeight = Math.ceil( - (metrics.actualBoundingBoxAscent || fontSize * 0.8) + - (metrics.actualBoundingBoxDescent || fontSize * 0.2), - ) + 2; + const cellWidth = metrics.width; + const glyphHeight = (metrics.actualBoundingBoxAscent || fontSize * 0.8) + (metrics.actualBoundingBoxDescent || fontSize * 0.2); + // Mirrors measureGhosttyCell: the line height is the larger of 1.35em and the glyph box. + const cellHeight = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); if (cellWidth < 1 || cellHeight < 1) return null; - const style = window.getComputedStyle(container); - const horizontalPadding = - (Number.parseInt(style.paddingLeft, 10) || 0) + - (Number.parseInt(style.paddingRight, 10) || 0); - const verticalPadding = - (Number.parseInt(style.paddingTop, 10) || 0) + - (Number.parseInt(style.paddingBottom, 10) || 0); - - // Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions. return { - cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)), - rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)), + cols: Math.max(2, Math.floor((container.clientWidth - CONTENT_PADDING * 2) / cellWidth)), + rows: Math.max(1, Math.floor((container.clientHeight - CONTENT_PADDING * 2) / cellHeight)), }; }; @@ -113,184 +112,84 @@ type Props = { enableTouchScroll?: boolean; autoFocus?: boolean; isVisible?: boolean; + /** Surface construction, injectable for tests. */ + createSurface?: TerminalSurfaceFactory; }; const TerminalViewport = React.forwardRef(({ sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className, - enableTouchScroll = false, autoFocus = true, isVisible = true, + enableTouchScroll = false, autoFocus = true, isVisible = true, createSurface = createGhosttySurface, }, ref) => { + const { t } = useI18n(); const containerRef = React.useRef(null); - const terminalRef = React.useRef(null); - const fitRef = React.useRef(null); + const surfaceRef = React.useRef(null); const inputRef = React.useRef(onInput); const resizeRef = React.useRef(onResize); const provisionalSizeCallbackRef = React.useRef(onProvisionalSize); - const lastSizeRef = React.useRef(null); - const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); - const writeQueueRef = React.useRef(''); - const outputRewriteCarryRef = React.useRef(''); - const safeResetRef = React.useRef(getGhosttySafeResetSequence(theme.background)); - const writingRef = React.useRef(false); - // Incremented whenever the replay stream restarts, so a write completing from - // before the restart cannot clear the in-flight flag of a newer write. - const writeEpochRef = React.useRef(0); const visibleRef = React.useRef(isVisible); - const rendererReadyRef = React.useRef(false); + const labelsRef = React.useRef({ input: '', scrollbar: '' }); const [ready, setReady] = React.useState(0); - const [rendererGeneration, setRendererGeneration] = React.useState(0); inputRef.current = onInput; resizeRef.current = onResize; provisionalSizeCallbackRef.current = onProvisionalSize; visibleRef.current = isVisible; - safeResetRef.current = getGhosttySafeResetSequence(theme.background); + labelsRef.current = { + input: t('terminalView.viewport.inputAria'), + scrollbar: t('terminalView.viewport.scrollbarAria'), + }; React.useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const size = getProvisionalTerminalSize(container, fontFamily, fontSize); - provisionalSizeRef.current = size; if (size) (provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows); }, [fontFamily, fontSize]); - const fit = React.useCallback(() => { - const container = containerRef.current; - const terminal = terminalRef.current; - if (!container || !terminal || !fitRef.current || !visibleRef.current) return; - const bounds = container.getBoundingClientRect(); - if (bounds.width < 24 || bounds.height < 24) return; - try { - fitRef.current.fit(); - const next = { cols: terminal.cols, rows: terminal.rows }; - if (!lastSizeRef.current || lastSizeRef.current.cols !== next.cols || lastSizeRef.current.rows !== next.rows) { - lastSizeRef.current = next; - resizeRef.current(next.cols, next.rows); - } - if (!rendererReadyRef.current) { - rendererReadyRef.current = true; - setReady((value) => value + 1); - } - } catch { /* hidden or detached */ } - }, []); - - const flush = React.useCallback(() => { - if (writingRef.current || !writeQueueRef.current || !terminalRef.current) return; - const terminal = terminalRef.current; - const pending = writeQueueRef.current; - writeQueueRef.current = ''; - const rewritten = rewriteGhosttyDefaultBackgroundResets( - pending, - outputRewriteCarryRef.current, - safeResetRef.current, - ); - outputRewriteCarryRef.current = rewritten.carry; - if (!rewritten.data) { - if (writeQueueRef.current) flush(); - return; - } - writingRef.current = true; - const epoch = writeEpochRef.current; - terminal.write(rewritten.data, () => { - if (terminalRef.current !== terminal || writeEpochRef.current !== epoch) return; - writingRef.current = false; - if (writeQueueRef.current) flush(); - }); - }, []); - - /** - * Replay discontinuities (restart, reconnect, buffer reset) only need the VT - * state cleared. `Terminal.reset()` frees and rebuilds the WASM terminal while - * keeping the canvas, renderer and font atlas, so prefer it over remounting the - * whole terminal; the generation bump remains the fallback before the terminal - * exists. - */ - const recreateRenderer = React.useCallback(() => { - lastChunkRef.current = null; - writeQueueRef.current = ''; - outputRewriteCarryRef.current = ''; - writingRef.current = false; - writeEpochRef.current += 1; - const terminal = terminalRef.current; - if (!terminal) { - setRendererGeneration((value) => value + 1); - return; - } - try { - terminal.reset(); - const safeReset = safeResetRef.current; - if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); - } catch { - setRendererGeneration((value) => value + 1); - } - }, []); - + // The surface lives for the whole mount. Theme and font changes are applied + // in place below; only the container identity and the factory can recreate it. React.useEffect(() => { const container = containerRef.current; if (!container) return; let disposed = false; - let terminal: GhosttyTerminal | null = null; - let observer: ResizeObserver | null = null; - let resizeTimeout: ReturnType | null = null; - let fitFrame: number | null = null; - let subscriptions: Array<{ dispose: () => void }> = []; - const handleFocusIn = () => { - if (terminal && visibleRef.current) terminal.options.cursorBlink = true; - }; - const handleFocusOut = (event: FocusEvent) => { - if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return; - if (terminal) terminal.options.cursorBlink = false; - }; - const handleWindowFocus = () => { - if (terminal && visibleRef.current && container.contains(document.activeElement)) { - terminal.options.cursorBlink = true; - } - }; - const handleWindowBlur = () => { - if (terminal) terminal.options.cursorBlink = false; - }; + let surface: TerminalSurface | null = null; + const initialTheme = theme; + const initialFont = { family: fontFamily, size: fontSize }; + const initialMonoFont = monoFont; + const ownsTouch = !enableTouchScroll; - container.addEventListener('focusin', handleFocusIn); - container.addEventListener('focusout', handleFocusOut); - window.addEventListener('focus', handleWindowFocus); - window.addEventListener('blur', handleWindowBlur); - - const fonts = waitForTerminalFonts(monoFont); - Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => { + void (async () => { + await waitForMonoFont(initialMonoFont); if (disposed) return; - terminal = new module.Terminal({ - ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), - ...(provisionalSizeRef.current ?? {}), - }); - const fitAddon = new module.FitAddon(); - terminal.loadAddon(fitAddon); - terminal.open(container); - // ghostty-web marks the container contenteditable for touch IME input but - // sets autocapitalize/autocorrect only on its hidden textarea. Mobile - // keyboards (iOS and Android) therefore auto-capitalize the first letter - // of every terminal command; disable IME text mangling on the container. - container.setAttribute('autocapitalize', 'off'); - container.setAttribute('autocorrect', 'off'); - container.setAttribute('spellcheck', 'false'); - terminalRef.current = terminal; - fitRef.current = fitAddon; - subscriptions = [terminal.onData((data) => inputRef.current(data))]; - observer = new ResizeObserver(() => { - if (resizeTimeout) clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(fit, 80); - }); - observer.observe(container); - fit(); - const safeReset = safeResetRef.current; - if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); - fitFrame = requestAnimationFrame(fit); - if (!fontsLoaded) { - void fonts.loaded.then(() => { - if (!disposed && terminalRef.current === terminal) { - setRendererGeneration((value) => value + 1); - } + let created: TerminalSurface; + try { + created = await createSurface(container, { + theme: toGhosttyTheme(initialTheme), + font: initialFont, + get visible() { + return visibleRef.current; + }, + labels: labelsRef.current, + handleTouchPointer: ownsTouch, + onData: (data) => inputRef.current(data), + onResize: (cols, rows) => resizeRef.current(cols, rows), + onLinkActivate: (text) => { + void openExternalUrl(text); + }, }); + } catch (error) { + console.error('[terminal] failed to initialize the terminal renderer', error); + return; } - }); + if (disposed) { + created.dispose(); + return; + } + surface = created; + surfaceRef.current = created; + created.setVisible(visibleRef.current); + setReady((value) => value + 1); + })(); return () => { disposed = true; @@ -301,6 +200,7 @@ const TerminalViewport = React.forwardRef(({ const active = document.activeElement; if (active instanceof HTMLElement && container.contains(active)) { active.blur(); + // SAFETY: the Capacitor bridge installs window.Capacitor with getPlatform() on native shells only. const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; if (capacitor?.getPlatform?.() === 'android') { void import('@capacitor/keyboard') @@ -308,120 +208,56 @@ const TerminalViewport = React.forwardRef(({ .catch(() => undefined); } } - observer?.disconnect(); - if (resizeTimeout) clearTimeout(resizeTimeout); - if (fitFrame !== null) cancelAnimationFrame(fitFrame); - container.removeEventListener('focusin', handleFocusIn); - container.removeEventListener('focusout', handleFocusOut); - window.removeEventListener('focus', handleWindowFocus); - window.removeEventListener('blur', handleWindowBlur); - subscriptions.forEach((subscription) => subscription.dispose()); - terminal?.dispose(); - terminalRef.current = null; - fitRef.current = null; - lastSizeRef.current = null; + surface?.dispose(); + surface = null; + surfaceRef.current = null; lastChunkRef.current = null; - writeQueueRef.current = ''; - outputRewriteCarryRef.current = ''; - writingRef.current = false; - writeEpochRef.current += 1; - rendererReadyRef.current = false; }; - }, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]); + // Theme, font and touch mode are applied to the live surface by the effects below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createSurface]); React.useEffect(() => { - const terminal = terminalRef.current; - const container = containerRef.current; - if (!terminal || !container) return; - terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement); + surfaceRef.current?.setTheme(toGhosttyTheme(theme)); + }, [theme, ready]); + + React.useEffect(() => { + void surfaceRef.current?.setFont({ family: fontFamily, size: fontSize }); + }, [fontFamily, fontSize, ready]); + + React.useEffect(() => { + surfaceRef.current?.setVisible(isVisible); }, [isVisible, ready]); - /** - * Snapshot history was laid out by the shell for the PTY size recorded on the - * chunk. Writing it into an emulator of another width wraps or joins lines the - * shell never wrapped, and the shell's later SIGWINCH redraw only repaints - * from its own cursor row down, so the stray fragments stay on screen. Replay - * such a chunk at its own size and let the emulator reflow back to the fitted - * size; a subsequent PTY resize (when the sizes differ) makes the shell redraw - * on top of a consistent screen. - * - * Only valid while nothing is queued: the write must not overtake bytes that - * are still waiting for the emulator. - */ - const writeReplayAtDrawnSize = React.useCallback((terminal: GhosttyTerminal, chunk: TerminalChunk): boolean => { - if (!chunk.size || writingRef.current || writeQueueRef.current) return false; - const rewritten = rewriteGhosttyDefaultBackgroundResets( - chunk.replayData ?? chunk.data, - outputRewriteCarryRef.current, - safeResetRef.current, - ); - outputRewriteCarryRef.current = rewritten.carry; - if (!rewritten.data) return true; - const fitted = { cols: terminal.cols, rows: terminal.rows }; - const resizeForReplay = chunk.size.cols !== fitted.cols || chunk.size.rows !== fitted.rows; - if (resizeForReplay) terminal.resize(chunk.size.cols, chunk.size.rows); - try { - terminal.write(rewritten.data); - } finally { - if (resizeForReplay) terminal.resize(fitted.cols, fitted.rows); - } - return true; - }, []); - React.useEffect(() => { - const terminal = terminalRef.current; - if (!terminal) return; + const surface = surfaceRef.current; + if (!surface) return; const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current); - if (reset) recreateRenderer(); - if (pending.length === 0) return; - const queued = replay && writeReplayAtDrawnSize(terminal, pending[0]) ? pending.slice(1) : pending; - writeQueueRef.current += queued - .map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data) - .join(''); + if (replay) { + // Snapshot history is laid out for the PTY size recorded on its chunk; + // the surface replays it at that size and reflows to the fitted grid. + const [snapshot, ...live] = pending; + surface.resetAndWrite(snapshot ? (snapshot.replayData ?? snapshot.data) : '', snapshot?.size); + const liveData = live.map((chunk) => chunk.replayData ?? chunk.data).join(''); + if (liveData) surface.write(liveData); + } else if (reset) { + surface.resetAndWrite(''); + } else if (pending.length > 0) { + surface.write(pending.map((chunk) => chunk.data).join('')); + } lastChunkRef.current = chunks.at(-1)?.id ?? null; - flush(); - }, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]); + }, [chunks, ready]); React.useEffect(() => { if (!autoFocus || !isVisible) return; - const frame = requestAnimationFrame(() => terminalRef.current?.focus()); + const frame = requestAnimationFrame(() => surfaceRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [autoFocus, isVisible, ready, sessionKey]); React.useEffect(() => { const container = containerRef.current; - if (!enableTouchScroll || !container) return; - // ghostty-web only reads keydown/composition events and preventDefaults - // beforeinput without consuming it. Android IMEs deliver text via - // beforeinput (their keydown arrives as keyCode 229, which ghostty - // ignores), so forward those payloads to the terminal here. Composition - // updates are skipped: ghostty commits them itself on compositionend. - const handleBeforeInput = (event: Event) => { - const input = event as InputEvent; - if (input.isComposing) return; - switch (input.inputType) { - case 'insertText': - if (input.data) inputRef.current(input.data); - break; - case 'insertLineBreak': - case 'insertParagraph': - inputRef.current('\r'); - break; - case 'deleteContentBackward': - inputRef.current('\x7f'); - break; - default: - break; - } - }; - container.addEventListener('beforeinput', handleBeforeInput); - return () => container.removeEventListener('beforeinput', handleBeforeInput); - }, [enableTouchScroll, ready]); - - React.useEffect(() => { - const container = containerRef.current; - const terminal = terminalRef.current; - if (!enableTouchScroll || !container || !terminal) return; + const surface = surfaceRef.current; + if (!enableTouchScroll || !container || !surface) return; let pointerId: number | null = null; let longPressTimeout: ReturnType | null = null; let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle'; @@ -429,12 +265,12 @@ const TerminalViewport = React.forwardRef(({ let startY = 0; let lastY = 0; let remainder = 0; - let selectionFocus: TerminalCellPosition | null = null; - const lineHeight = Math.max(12, fontSize + 2); + const lineHeight = Math.max(12, Math.round(fontSize * 1.35)); // Android WebView only raises the soft keyboard for a native tap-focus; the // pointer-captured, touch-action:none tap here focuses programmatically, so // the IME must be summoned explicitly via the Capacitor Keyboard plugin. const showAndroidSoftKeyboard = () => { + // SAFETY: the Capacitor bridge installs window.Capacitor with getPlatform() on native shells only. const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; if (capacitor?.getPlatform?.() !== 'android') return; void import('@capacitor/keyboard') @@ -446,37 +282,6 @@ const TerminalViewport = React.forwardRef(({ clearTimeout(longPressTimeout); longPressTimeout = null; }; - const cellFromPoint = (clientX: number, clientY: number) => { - const canvas = container.querySelector('canvas'); - if (!canvas) return null; - return getTerminalCellFromPoint(clientX, clientY, canvas.getBoundingClientRect(), terminal.cols, terminal.rows); - }; - const dispatchSelectionMouseEvent = ( - type: 'mousedown' | 'mousemove', - cell: TerminalCellPosition, - ) => { - const canvas = container.querySelector('canvas'); - if (!canvas) return; - const bounds = canvas.getBoundingClientRect(); - const clientX = bounds.left + ((cell.column + 0.5) / terminal.cols) * bounds.width; - const clientY = bounds.top + ((cell.row + 0.5) / terminal.rows) * bounds.height; - canvas.dispatchEvent(new MouseEvent(type, { - bubbles: true, - cancelable: true, - button: 0, - buttons: 1, - clientX, - clientY, - })); - }; - const finishSelection = () => { - document.dispatchEvent(new MouseEvent('mouseup', { - bubbles: true, - cancelable: true, - button: 0, - buttons: 0, - })); - }; const down = (event: PointerEvent) => { if (event.pointerType !== 'touch' || pointerId !== null) return; pointerId = event.pointerId; @@ -485,35 +290,18 @@ const TerminalViewport = React.forwardRef(({ startY = event.clientY; lastY = event.clientY; remainder = 0; - selectionFocus = null; container.setPointerCapture(event.pointerId); longPressTimeout = setTimeout(() => { longPressTimeout = null; if (pointerId !== event.pointerId || gesture !== 'pending') return; - const cell = cellFromPoint(startX, startY); - if (!cell) return; - - const buffer = terminal.buffer.active; - const lineIndex = Math.max(0, buffer.length - terminal.rows - buffer.viewportY + cell.row); - const line = buffer.getLine(lineIndex); - const cells = Array.from({ length: terminal.cols }, (_, column) => line?.getCell(column)?.getChars() ?? ''); - const word = getTerminalWordRange(cells, cell.column); - const selectionAnchor = { column: word.startColumn, row: cell.row }; - selectionFocus = { column: word.endColumn, row: cell.row }; - gesture = 'selecting'; - dispatchSelectionMouseEvent('mousedown', selectionAnchor); - dispatchSelectionMouseEvent('mousemove', selectionFocus); + if (surface.selectWordAt(startX, startY)) gesture = 'selecting'; }, 350); }; const move = (event: PointerEvent) => { if (pointerId !== event.pointerId) return; if (gesture === 'selecting') { - const focus = cellFromPoint(event.clientX, event.clientY); - if (focus && (!selectionFocus || focus.column !== selectionFocus.column || focus.row !== selectionFocus.row)) { - selectionFocus = focus; - dispatchSelectionMouseEvent('mousemove', focus); - } + surface.extendSelectionTo(event.clientX, event.clientY); if (event.cancelable) event.preventDefault(); return; } @@ -530,32 +318,23 @@ const TerminalViewport = React.forwardRef(({ lastY = event.clientY; remainder += delta; const lines = Math.trunc(remainder / lineHeight); - if (lines) { terminal.scrollLines(lines); remainder -= lines * lineHeight; } + if (lines) { surface.scrollLines(lines); remainder -= lines * lineHeight; } if (event.cancelable) event.preventDefault(); }; - const up = (event: PointerEvent) => { + const finish = (event: PointerEvent, focusOnTap: boolean) => { if (pointerId !== event.pointerId) return; - const shouldFocus = gesture === 'pending'; - const shouldFinishSelection = gesture === 'selecting'; + const shouldFocus = focusOnTap && gesture === 'pending'; clearLongPress(); if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); pointerId = null; gesture = 'idle'; - if (shouldFinishSelection) finishSelection(); if (shouldFocus) { - terminal.focus(); + surface.focus(); showAndroidSoftKeyboard(); } }; - const cancel = (event: PointerEvent) => { - if (pointerId !== event.pointerId) return; - const shouldFinishSelection = gesture === 'selecting'; - clearLongPress(); - if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); - pointerId = null; - gesture = 'idle'; - if (shouldFinishSelection) finishSelection(); - }; + const up = (event: PointerEvent) => finish(event, true); + const cancel = (event: PointerEvent) => finish(event, false); container.addEventListener('pointerdown', down); container.addEventListener('pointermove', move, { passive: false }); container.addEventListener('pointerup', up); @@ -570,22 +349,27 @@ const TerminalViewport = React.forwardRef(({ }, [enableTouchScroll, fontSize, ready]); React.useImperativeHandle(ref, () => ({ - focus: () => terminalRef.current?.focus(), - fit, + focus: () => surfaceRef.current?.focus(), + fit: () => { + const surface = surfaceRef.current; + if (!surface) return; + surface.fit(); + surface.refresh(); + }, getSelection: () => { - const terminal = terminalRef.current; - const range = terminal?.getSelectionPosition(); - const text = terminal?.getSelection() ?? ''; + const surface = surfaceRef.current; + const range = surface?.getSelectionPosition(); + const text = surface?.getSelection() ?? ''; if (!range || !text.trim()) return null; return { text, startLine: range.start.y + 1, endLine: range.end.y + 1 }; }, - }), [fit]); + }), []); return (
); }); diff --git a/packages/ui/src/components/views/TerminalView.test.tsx b/packages/ui/src/components/views/TerminalView.test.tsx index 54416586..dac5c437 100644 --- a/packages/ui/src/components/views/TerminalView.test.tsx +++ b/packages/ui/src/components/views/TerminalView.test.tsx @@ -98,7 +98,7 @@ mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock })); mock.module('@/stores/useInlineCommentDraftStore', () => ({ useInlineCommentDraftStore: () => ({ addDraft: () => undefined }) })); mock.module('@/components/terminal/TerminalViewport', () => ({ TerminalViewport: React.forwardRef(function TerminalViewportMock( - { sessionKey, chunks, isVisible }: { sessionKey: string; chunks: unknown[]; isVisible: boolean }, + { sessionKey, chunks, isVisible, onResize }: { sessionKey: string; chunks: unknown[]; isVisible: boolean; onResize: (cols: number, rows: number) => void }, ref: React.ForwardedRef<{ focus: () => void; fit: () => void; getSelection: () => null }>, ) { React.useImperativeHandle(ref, () => ({ @@ -106,6 +106,11 @@ mock.module('@/components/terminal/TerminalViewport', () => ({ fit: () => undefined, getSelection: () => null, }), []); + // A real surface reports its fitted grid once it is visible; a visible tab + // spawns its shell only after that report. + React.useEffect(() => { + if (isVisible) onResize(100, 30); + }, [isVisible, onResize]); return React.createElement('div', { 'data-terminal-viewport': 'true', @@ -303,7 +308,7 @@ describe('TerminalView project action tab indicator', () => { expect(ensureDirectoryCalls).not.toContain('/missing-repo'); expect(createSessionCalls.length).toBe(0); expect(host.querySelector('[data-tabs-strip="terminal"]')).toBeNull(); - expect(host.querySelector('[data-terminal-viewport="true"]')?.getAttribute('data-chunk-count')).toBe('0'); + expect(host.querySelector('[data-terminal-viewport="true"]')).toBeNull(); }); test('includes the terminal directory in the viewport identity key', async () => { diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 7f27db70..93f60716 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { ACTIVE_PROJECT_ACTION_LIFECYCLES, EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore'; +import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { type TerminalStreamEvent } from '@/lib/api/types'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -9,9 +9,13 @@ import { useFontPreferences } from '@/hooks/useFontPreferences'; import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions'; import { convertThemeToXterm } from '@/lib/terminalTheme'; import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport'; +import type { MonoFontOption } from '@/lib/fontOptions'; +import type { TerminalTheme } from '@/lib/terminalTheme'; import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from '@/components/icon/icons'; @@ -32,6 +36,57 @@ type TerminalViewProps = { }; const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const; + +type TerminalTabViewportProps = { + directory: string; + tabId: string; + isActive: boolean; + isTerminalVisible: boolean; + registerController: (tabId: string, controller: TerminalController | null) => void; + onInput: (data: string) => void; + onResize: (cols: number, rows: number) => void; + onProvisionalSize: (cols: number, rows: number) => void; + theme: TerminalTheme; + monoFont: MonoFontOption; + fontFamily: string; + fontSize: number; + enableTouchScroll: boolean; +}; + +/** + * One mounted emulator per tab. Inactive tabs stay mounted but hidden so + * switching back shows the last drawn screen at once instead of rebuilding + * the WASM terminal, re-measuring fonts and replaying history from scratch. + * Only the active tab holds a stream; its buffer refresh replays in place. + */ +const TerminalTabViewport: React.FC = ({ + directory, tabId, isActive, isTerminalVisible, registerController, + onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, enableTouchScroll, +}) => { + // Scrollback is a leaf subscription: streaming output must not rerender the tab strip. + const chunks = useTerminalStore((s) => s.getBuffer(directory, tabId).chunks); + const viewportKey = `${directory}::${tabId}`; + return ( +
+ registerController(tabId, controller)} + sessionKey={viewportKey} + chunks={chunks} + onInput={onInput} + onResize={onResize} + onProvisionalSize={onProvisionalSize} + theme={theme} + monoFont={monoFont} + fontFamily={fontFamily} + fontSize={fontSize} + enableTouchScroll={enableTouchScroll} + autoFocus={isTerminalVisible && isActive} + isVisible={isTerminalVisible && isActive} + /> +
+ ); +}; + const resolveTabIconName = (iconKey: string | null): IconName => { const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === iconKey); return matchedIcon?.Icon ?? 'terminal'; @@ -126,10 +181,6 @@ export const TerminalView: React.FC = ({ visible, directory } const terminalSessionId = activeTab?.terminalSessionId ?? null; const terminalLifecycle = activeTab?.lifecycle ?? 'idle'; const isActionTab = activeTab?.purpose.type === 'project-action'; - // Scrollback is a leaf subscription: streaming output must not rerender the tab strip. - const bufferChunks = useTerminalStore((s) => ( - terminalDirectory && activeTabId ? s.getBuffer(terminalDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks - )); const isConnecting = activeTab?.isConnecting ?? false; const previewUrl = activeTab?.previewUrl ?? null; @@ -145,7 +196,14 @@ export const TerminalView: React.FC = ({ visible, directory } const terminalIdRef = React.useRef(terminalSessionId); const directoryRef = React.useRef(terminalDirectory); const terminalControllerRef = React.useRef(null); + const tabControllersRef = React.useRef(new Map()); const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); + // The grid Ghostty actually fitted for a tab. A visible tab spawns its shell + // at this size and not before: a shell started wider than the real grid + // prints its first prompt for that width, and after the corrective resize + // zsh only repaints the prompt row, leaving the `%` end-of-line mark above it. + const fittedViewportRef = React.useRef<{ tabId: string; cols: number; rows: number } | null>(null); + const isTerminalVisibleRef = React.useRef(false); const pendingTerminalCreatesRef = React.useRef(new Set()); const previewScanTailRef = React.useRef(''); const pendingPreviewProbeUrlsRef = React.useRef>(new Set()); @@ -175,6 +233,7 @@ export const TerminalView: React.FC = ({ visible, directory } }, [useTouchTerminalInput]); const isTerminalVisible = visible ?? false; + isTerminalVisibleRef.current = isTerminalVisible; const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible); React.useEffect(() => { @@ -197,6 +256,16 @@ export const TerminalView: React.FC = ({ visible, directory } resetTerminalPreviewScan(); }, [activeTabId, resetTerminalPreviewScan]); + React.useLayoutEffect(() => { + terminalControllerRef.current = activeTabId ? (tabControllersRef.current.get(activeTabId) ?? null) : null; + }, [activeTabId]); + + const registerTabController = React.useCallback((tabId: string, controller: TerminalController | null) => { + if (controller) tabControllersRef.current.set(tabId, controller); + else tabControllersRef.current.delete(tabId); + if (tabId === activeTabIdRef.current) terminalControllerRef.current = controller; + }, []); + React.useEffect(() => { directoryRef.current = terminalDirectory; }, [terminalDirectory]); @@ -418,6 +487,79 @@ export const TerminalView: React.FC = ({ visible, directory } ] ); + // Spawns the PTY for a tab. Pending creates are single-flight per tab; + // the session effect and the first fitted-grid report both funnel here. + const createTerminalSession = React.useCallback( + async (directory: string, tabId: string, initialSize: { cols: number; rows: number }) => { + const createKey = `${directory}\u0000${tabId}`; + if (pendingTerminalCreatesRef.current.has(createKey)) { + return; + } + pendingTerminalCreatesRef.current.add(createKey); + + setConnectionError(null); + setIsFatalError(false); + setIsReconnectPending(false); + setConnecting(directory, tabId, true); + try { + const session = await terminal.createSession({ + cwd: directory, + sessionId: tabId, + cols: initialSize.cols, + rows: initialSize.rows, + shell: terminalShell, + loginShell: terminalLoginShell, + ...terminalAppearanceRef.current, + }); + + const stillActive = + directoryRef.current === directory && + activeTabIdRef.current === tabId; + + const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); + if (!owningTab) { + try { + await terminal.close(session.sessionId); + } catch { /* ignored */ } + return; + } + + setTabSessionId(directory, tabId, session.sessionId); + if (!stillActive) return; + + const viewportSize = lastViewportSizeRef.current; + if ( + viewportSize && + (viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows) + ) { + void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {}); + } + // Storing the session ID reruns the session effect. Let that + // effect own stream startup. + return; + } catch (error) { + const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); + if (!owningTab || owningTab.terminalSessionId) return; + + setConnecting(directory, tabId, false); + // Use current store ownership so a rejected create cannot + // leave a tab spinning that no longer owns the request. + if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return; + setConnectionError( + error instanceof Error + ? error.message + : t('terminalView.error.startSessionFailed') + ); + setIsFatalError(true); + setIsReconnectPending(false); + return; + } finally { + pendingTerminalCreatesRef.current.delete(createKey); + } + }, + [setConnecting, setTabSessionId, t, terminal, terminalLoginShell, terminalShell] + ); + React.useEffect(() => { let cancelled = false; @@ -475,80 +617,16 @@ export const TerminalView: React.FC = ({ visible, directory } return; } - const createKey = `${directory}\u0000${tabId}`; - if (pendingTerminalCreatesRef.current.has(createKey)) { - return; - } - - // Launch the shell while Ghostty is still loading and fitting. - // The backend accepts 80x24, then receives the measured size as - // soon as the viewport is ready. - const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE; - pendingTerminalCreatesRef.current.add(createKey); - - setConnectionError(null); - setIsFatalError(false); - setIsReconnectPending(false); - setConnecting(directory, tabId, true); - try { - const session = await terminal.createSession({ - cwd: directory, - sessionId: tabId, - cols: initialSize.cols, - rows: initialSize.rows, - shell: terminalShell, - loginShell: terminalLoginShell, - ...terminalAppearanceRef.current, - }); - - const stillActive = - !cancelled && - directoryRef.current === directory && - activeTabIdRef.current === tabId; - - const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); - if (!owningTab) { - try { - await terminal.close(session.sessionId); - } catch { /* ignored */ } - return; - } - - setTabSessionId(directory, tabId, session.sessionId); - if (!stillActive) return; - - const viewportSize = lastViewportSizeRef.current; - if ( - viewportSize && - (viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows) - ) { - void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {}); - } - // Storing the session ID reruns this effect. Let that next - // effect own stream startup: starting here would be torn - // down immediately by this effect's cleanup. - return; - } catch (error) { - const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); - if (!owningTab || owningTab.terminalSessionId) return; - - setConnecting(directory, tabId, false); - // Strict Mode replaces the first effect while its create - // request is pending. `cancelled` therefore does not mean - // this tab stopped owning the request; use current store - // ownership so a rejected create cannot leave it spinning. - if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return; - setConnectionError( - error instanceof Error - ? error.message - : t('terminalView.error.startSessionFailed') - ); - setIsFatalError(true); - setIsReconnectPending(false); - return; - } finally { - pendingTerminalCreatesRef.current.delete(createKey); - } + // A visible tab waits for Ghostty's fitted grid; the resize + // handler spawns it the moment that grid arrives. A hidden tab + // cannot be fitted, so it launches at the container estimate or + // 80x24 and resizes once shown. + const fitted = fittedViewportRef.current; + const fittedSize = fitted && fitted.tabId === tabId ? { cols: fitted.cols, rows: fitted.rows } : null; + if (isTerminalVisibleRef.current && !fittedSize) return; + const initialSize = fittedSize ?? lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE; + void createTerminalSession(directory, tabId, initialSize); + return; } if (!terminalId || cancelled) return; @@ -573,6 +651,7 @@ export const TerminalView: React.FC = ({ visible, directory } terminalLifecycle, activeTabId, hasOpenedTerminalViewport, + createTerminalSession, enableTabs, terminalHydrated, ensureDirectory, @@ -687,6 +766,17 @@ export const TerminalView: React.FC = ({ visible, directory } }); }, [activeTab, addContextDraft, contextDirectory, currentSessionId, newSessionDraft?.open]); + // Touch hosts have no keyboard shortcut for copy, so the toolbar offers the + // same action the desktop gets from Cmd/Ctrl+C on a selection. + const handleCopySelection = React.useCallback(() => { + const selection = terminalControllerRef.current?.getSelection(); + if (!selection?.text) return; + void copyTextToClipboard(selection.text).then((result) => { + if (result.ok) toast.success(t('terminalView.toast.selectionCopied')); + else toast.error(t('terminalView.toast.copyFailed')); + }); + }, [t]); + const handleSelectTab = React.useCallback( (tabId: string) => { if (!terminalDirectory) return; @@ -766,14 +856,25 @@ export const TerminalView: React.FC = ({ visible, directory } if (!previous || previous.cols !== cols || previous.rows !== rows) { lastViewportSizeRef.current = { cols, rows }; } + const tabId = activeTabIdRef.current; + const directory = directoryRef.current; + if (tabId) fittedViewportRef.current = { tabId, cols, rows }; if (!isTerminalVisible) { return; } + // The fitted grid is what a visible tab was waiting for to spawn. + const tab = tabId && directory + ? useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId) + : undefined; + if (tab && directory && tabId && !tab.terminalSessionId && tab.lifecycle !== 'exited' && tab.purpose.type !== 'project-action') { + void createTerminalSession(directory, tabId, { cols, rows }); + return; + } const terminalId = terminalIdRef.current; if (!terminalId) return; void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {}); }, - [isTerminalVisible, terminal] + [createTerminalSession, isTerminalVisible, terminal] ); const handleModifierToggle = React.useCallback( @@ -865,6 +966,7 @@ export const TerminalView: React.FC = ({ visible, directory } // here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font // atlas) a second time the moment `createSession` resolved, doubling the cost // of every terminal open. Session changes are handled by the chunk replay path. + // Every tab keeps its viewport mounted; this key names the active one. const terminalViewportKey = `${terminalDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`; React.useEffect(() => { @@ -933,6 +1035,10 @@ export const TerminalView: React.FC = ({ visible, directory } const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending; const shouldRenderViewport = hasOpenedTerminalViewport; + // Without tabs (VS Code) only the first tab exists; with tabs every open tab stays mounted. + const mountedTabIds = enableTabs + ? (directoryTerminalState?.tabs ?? []).map((tab) => tab.id) + : (activeTabId ? [activeTabId] : []); const quickKeySize: 'lg' | 'xs' = isTouchTerminal ? 'lg' : 'xs'; const quickKeyIconClass = isTouchTerminal ? 'w-10 p-0' : 'w-9 p-0'; const preserveTerminalFocus = (event: React.PointerEvent) => { @@ -1094,6 +1200,17 @@ export const TerminalView: React.FC = ({ visible, directory } > + {previewUrl ? (