feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter
The terminal ran on the ghostty-web npm package plus a hand-written patch, and every rendering bug (recycled rows, duplicated reflow fragments, prompt artifacts) had to be worked around from outside. The emulator now is the official libghostty-vt C ABI compiled to WebAssembly, driven by a browser adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in packages/ui/src/lib/ghostty. The artifact is reproducible with scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on macOS 27 SDKs. On top of the port: one WASM instance per page with every tab kept mounted and hidden tabs paused; history replayed at the PTY size it was drawn for; shells spawned only after the first fitted grid so zsh never prints the PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn procedurally to the exact cell so TUI borders and block logos have no gaps between rows; a software-rasterized canvas so Gecko renders every tab's text with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN fetch; touch selection and scrolling driven through the surface API; a copy button in the tab strip for touch hosts; localized aria labels. Testing: bun tests run the real WASM (reflow, palette, replay isolation, recycled rows, box glyph geometry); viewport and view tests use a surface double; verified in Chromium and Zen (windowed and headless) for crisp text, new tabs, panel reopen, resize and box glyph rendering; package type-check, oxlint/eslint on new files, web build.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+174
@@ -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" <<EOF
|
||||
#!/bin/sh
|
||||
case "\$*" in
|
||||
*--show-sdk-path*) echo "${shim_sdk}" ;;
|
||||
*) exec /usr/bin/xcrun "\$@" ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "${shim_root}/bin/xcrun"
|
||||
export PATH="${shim_root}/bin:${PATH}"
|
||||
log "using patched macOS SDK stubs from ${shim_sdk}"
|
||||
}
|
||||
|
||||
ensure_ghostty_source() {
|
||||
require_cmd git
|
||||
if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then
|
||||
mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")"
|
||||
log "cloning Ghostty ${GHOSTTY_REVISION}"
|
||||
git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \
|
||||
"${GHOSTTY_SOURCE_DIR}"
|
||||
fi
|
||||
|
||||
local actual_revision
|
||||
actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()');
|
||||
|
||||
@@ -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(
|
||||
<TerminalViewport
|
||||
sessionKey="session-1"
|
||||
chunks={chunks}
|
||||
onInput={() => undefined}
|
||||
onResize={() => undefined}
|
||||
theme={theme}
|
||||
monoFont="geist-mono"
|
||||
fontFamily="Geist Mono"
|
||||
fontSize={14}
|
||||
/>,
|
||||
<I18nProvider>
|
||||
<TerminalViewport
|
||||
sessionKey="session-1"
|
||||
chunks={chunks}
|
||||
onInput={() => undefined}
|
||||
onResize={() => undefined}
|
||||
theme={theme}
|
||||
monoFont="system-mono"
|
||||
fontFamily="Menlo"
|
||||
fontSize={14}
|
||||
isVisible={isVisible}
|
||||
createSurface={createSurface}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 = `[7m%[0m${' '.repeat(93)}\r \r[J~ ❯ `;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GhosttyRuntime> | null = null;
|
||||
const loadGhostty = (): Promise<GhosttyRuntime> =>
|
||||
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<void> =>
|
||||
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<boolean>((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<TerminalSurface>;
|
||||
|
||||
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<void> =>
|
||||
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<TerminalController, Props>(({
|
||||
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<HTMLDivElement>(null);
|
||||
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
|
||||
const fitRef = React.useRef<FitAddon | null>(null);
|
||||
const surfaceRef = React.useRef<TerminalSurface | null>(null);
|
||||
const inputRef = React.useRef(onInput);
|
||||
const resizeRef = React.useRef(onResize);
|
||||
const provisionalSizeCallbackRef = React.useRef(onProvisionalSize);
|
||||
const lastSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const lastChunkRef = React.useRef<number | null>(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<typeof setTimeout> | 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<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
.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<typeof setTimeout> | null = null;
|
||||
let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle';
|
||||
@@ -429,12 +265,12 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
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<TerminalController, Props>(({
|
||||
}, [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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-terminal-owner="main"
|
||||
className={cn('terminal-viewport-container h-full w-full overflow-hidden touch-none', className)}
|
||||
className={cn('terminal-viewport-container relative h-full w-full overflow-hidden touch-none', className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<TerminalTabViewportProps> = ({
|
||||
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 (
|
||||
<div className={cn('h-full w-full', !isActive && 'hidden')}>
|
||||
<TerminalViewport
|
||||
ref={(controller) => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible, directory }
|
||||
const terminalIdRef = React.useRef<string | null>(terminalSessionId);
|
||||
const directoryRef = React.useRef<string | null>(terminalDirectory);
|
||||
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
||||
const tabControllersRef = React.useRef(new Map<string, TerminalController>());
|
||||
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<string>());
|
||||
const previewScanTailRef = React.useRef('');
|
||||
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
|
||||
@@ -175,6 +233,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible, directory }
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
hasOpenedTerminalViewport,
|
||||
createTerminalSession,
|
||||
enableTabs,
|
||||
terminalHydrated,
|
||||
ensureDirectory,
|
||||
@@ -687,6 +766,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<HTMLButtonElement>) => {
|
||||
@@ -1094,6 +1200,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
|
||||
>
|
||||
<Icon name="attachment-2" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCopySelection}
|
||||
title={t('terminalView.actions.copySelection')}
|
||||
aria-label={t('terminalView.actions.copySelection')}
|
||||
>
|
||||
<Icon name="file-copy" className="h-4 w-4" />
|
||||
</Button>
|
||||
{previewUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1132,14 +1249,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
|
||||
style={{ backgroundColor: xtermTheme.background }}
|
||||
>
|
||||
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
|
||||
{shouldRenderViewport ? (
|
||||
<TerminalViewport
|
||||
key={terminalViewportKey}
|
||||
ref={(controller) => {
|
||||
terminalControllerRef.current = controller;
|
||||
}}
|
||||
sessionKey={terminalViewportKey}
|
||||
chunks={bufferChunks}
|
||||
{shouldRenderViewport ? mountedTabIds.map((tabId) => (
|
||||
<TerminalTabViewport
|
||||
key={`${terminalDirectory}::${tabId}`}
|
||||
directory={terminalDirectory}
|
||||
tabId={tabId}
|
||||
isActive={tabId === activeTabId}
|
||||
isTerminalVisible={isTerminalVisible}
|
||||
registerController={registerTabController}
|
||||
onInput={handleViewportInput}
|
||||
onResize={handleViewportResize}
|
||||
onProvisionalSize={handleProvisionalSize}
|
||||
@@ -1148,10 +1265,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
|
||||
fontFamily={resolvedFontStack}
|
||||
fontSize={terminalFontSize}
|
||||
enableTouchScroll={useTouchTerminalInput}
|
||||
autoFocus={isTerminalVisible}
|
||||
isVisible={isTerminalVisible}
|
||||
/>
|
||||
) : null}
|
||||
)) : null}
|
||||
</div>
|
||||
{!isReconnectPending && connectionError && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
|
||||
|
||||
@@ -38,29 +38,33 @@ describe('terminal viewport remount guard', () => {
|
||||
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
|
||||
});
|
||||
|
||||
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
|
||||
const start = terminalViewportSource.indexOf('const recreateRenderer = React.useCallback(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = terminalViewportSource.slice(start, terminalViewportSource.indexOf('}, []);', start));
|
||||
expect(body).toContain('terminal.reset()');
|
||||
// The generation bump stays only as the fallback when no terminal exists yet.
|
||||
expect(body.indexOf('if (!terminal)')).toBeLessThan(body.indexOf('terminal.reset()'));
|
||||
test('replay discontinuities reset the surface in place instead of remounting it', () => {
|
||||
expect(terminalViewportSource).toContain('surface.resetAndWrite(');
|
||||
expect(terminalViewportSource).not.toContain('setRendererGeneration');
|
||||
});
|
||||
|
||||
test('every open tab keeps its viewport mounted and only the active one is visible', () => {
|
||||
expect(terminalViewSource).toContain('mountedTabIds.map((tabId) =>');
|
||||
expect(terminalViewSource).toContain("!isActive && 'hidden'");
|
||||
expect(terminalViewSource).toContain('isVisible={isTerminalVisible && isActive}');
|
||||
});
|
||||
|
||||
test('scrollback is read from the buffer slice, not from the tab', () => {
|
||||
expect(terminalViewSource).toContain('getBuffer(');
|
||||
expect(terminalViewSource).toContain('s.getBuffer(directory, tabId).chunks');
|
||||
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
|
||||
});
|
||||
|
||||
test('starts the PTY before Ghostty reports its first viewport size', () => {
|
||||
test('spawns a visible tab at the fitted grid and a hidden one at the estimate', () => {
|
||||
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
|
||||
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
|
||||
expect(terminalViewSource).toContain('const initialSize = fittedSize ?? lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
|
||||
// A visible tab waits for the fitted grid so zsh never prints its first prompt for a wider PTY.
|
||||
expect(terminalViewSource).toContain('if (isTerminalVisibleRef.current && !fittedSize) return;');
|
||||
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
|
||||
expect(terminalViewSource).toContain('cols: initialSize.cols');
|
||||
expect(terminalViewSource).toContain('rows: initialSize.rows');
|
||||
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
|
||||
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
|
||||
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
|
||||
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
|
||||
});
|
||||
|
||||
test('deduplicates create attempts while the viewport layout settles', () => {
|
||||
@@ -69,19 +73,19 @@ describe('terminal viewport remount guard', () => {
|
||||
});
|
||||
|
||||
test('lets the session-ID effect own stream startup after creating a tab', () => {
|
||||
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
|
||||
const createEnd = terminalViewSource.indexOf('if (!terminalId || cancelled) return;', createStart);
|
||||
const createStart = terminalViewSource.indexOf('const createTerminalSession = React.useCallback(');
|
||||
const createEnd = terminalViewSource.indexOf('React.useEffect(() => {', createStart);
|
||||
expect(createStart).toBeGreaterThan(-1);
|
||||
expect(createEnd).toBeGreaterThan(createStart);
|
||||
const createBlock = terminalViewSource.slice(createStart, createEnd);
|
||||
|
||||
expect(createBlock).toContain('setTabSessionId(directory, tabId, session.sessionId);');
|
||||
expect(createBlock).toContain('Let that next');
|
||||
expect(createBlock).toContain('Let that');
|
||||
expect(createBlock).not.toContain('startStream(');
|
||||
});
|
||||
|
||||
test('clears a current tab from connecting when a strict-mode create rejects', () => {
|
||||
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
|
||||
const createStart = terminalViewSource.indexOf('const createTerminalSession = React.useCallback(');
|
||||
const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart);
|
||||
const catchEnd = terminalViewSource.indexOf('} finally {', catchStart);
|
||||
expect(catchStart).toBeGreaterThan(createStart);
|
||||
@@ -97,16 +101,14 @@ describe('terminal viewport remount guard', () => {
|
||||
test('derives the initial PTY size before Ghostty mounts', () => {
|
||||
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
|
||||
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
|
||||
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
||||
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
||||
expect(terminalViewportSource).toContain('(provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows)');
|
||||
});
|
||||
|
||||
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
|
||||
expect(terminalViewportSource).toContain('loadMonoFont(font)');
|
||||
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
|
||||
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
|
||||
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
|
||||
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
|
||||
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
|
||||
test('waits for the selected mono face with a bound before the surface measures the grid', () => {
|
||||
expect(terminalViewportSource).toContain('const TERMINAL_FONT_WAIT_MS = 2000;');
|
||||
expect(terminalViewportSource).toContain('await waitForMonoFont(initialMonoFont);');
|
||||
// A surface lives for the whole mount; fonts and theme are applied in place.
|
||||
expect(terminalViewportSource).toContain('surfaceRef.current?.setFont(');
|
||||
expect(terminalViewportSource).toContain('surfaceRef.current?.setTheme(');
|
||||
});
|
||||
});
|
||||
|
||||
+40
-39
@@ -1666,44 +1666,54 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
/* Hide browser/system caret for Ghostty's own input surfaces.
|
||||
Terminal cursor is rendered by the canvas renderer. */
|
||||
.terminal-viewport-container[contenteditable="true"][aria-label="Terminal input"],
|
||||
.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] {
|
||||
/* libghostty-vt terminal surface. The canvas draws the grid and cursor; the
|
||||
textarea only receives keyboard, IME and clipboard events and follows the
|
||||
cursor so IME candidates appear where the user types. */
|
||||
.terminal-viewport-container .oc-terminal-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.terminal-viewport-container .oc-terminal-input {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
resize: none;
|
||||
pointer-events: none;
|
||||
caret-color: transparent !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
textarea[aria-label="Terminal input"],
|
||||
input[aria-label="Terminal input"] {
|
||||
caret-color: transparent !important;
|
||||
outline: none !important;
|
||||
.terminal-viewport-container .oc-terminal-scrollbar {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 1px;
|
||||
bottom: 4px;
|
||||
z-index: 1;
|
||||
width: 8px;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* When touch terminal input uses the portaled overlay, keep Ghostty's
|
||||
internal editable surfaces fully non-painting to avoid duplicate cursors. */
|
||||
.terminal-viewport-container[data-hidden-input-overlay-active="true"] textarea:not([data-terminal-hidden-input="true"]),
|
||||
.terminal-viewport-container[data-hidden-input-overlay-active="true"] input:not([data-terminal-hidden-input="true"]),
|
||||
.terminal-viewport-container[data-hidden-input-overlay-active="true"] [contenteditable="true"] {
|
||||
caret-color: transparent !important;
|
||||
color: transparent !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
.terminal-viewport-container .oc-terminal-scrollbar-thumb {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
top: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--oc-scrollbar-thumb);
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.terminal-viewport-container[data-hidden-input-overlay-active="true"] textarea:not([data-terminal-hidden-input="true"]),
|
||||
.terminal-viewport-container[data-hidden-input-overlay-active="true"] input:not([data-terminal-hidden-input="true"]),
|
||||
.terminal-viewport-container[data-hidden-input-overlay-active="true"] [contenteditable="true"] {
|
||||
opacity: 0 !important;
|
||||
font-size: 0 !important;
|
||||
line-height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
.terminal-viewport-container .oc-terminal-scrollbar:hover .oc-terminal-scrollbar-thumb {
|
||||
background: var(--oc-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Mobile Terminal Optimizations */
|
||||
@@ -1714,15 +1724,6 @@ input[aria-label="Terminal input"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* Font loading states */
|
||||
.fonts-loading .terminal-viewport-container {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.fonts-loaded .terminal-viewport-container {
|
||||
font-family: "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@keyframes oc-busy-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# libghostty-vt Terminal Adapter
|
||||
|
||||
## Ownership
|
||||
|
||||
This directory is OpenChamber's browser adapter for the official `libghostty-vt` C ABI, adapted from T3 Code (see `LICENSE-T3CODE`). It replaces the `ghostty-web` npm package: the emulator, the renderer and the input layer are all owned here, so a terminal bug is fixed in this directory rather than patched around in `node_modules`.
|
||||
|
||||
- `runtime.ts` owns the single WebAssembly instance per page, the C struct layouts read from `ghostty_type_json`, allocation helpers, and the PTY write callback trampoline (embedded bytes compiled from `../../../scripts/ghostty-write-pty.zig`).
|
||||
- `core.ts` owns one terminal's Ghostty handles: VT writes, resize, theme and 256-color palette, selection, key/mouse/paste encoding, and the render-state snapshot (`GhosttySnapshot`) the renderer draws.
|
||||
- `renderer.ts` paints a snapshot into a Canvas 2D context: background runs, text runs, decorations, cursor. It measures the cell from the faces that will render.
|
||||
- `surface.ts` owns the DOM: canvas, hidden textarea (keyboard, IME, clipboard), scrollbar, pointer selection, link hover/activation, mouse reporting, wheel scrolling, cursor blink, DPR changes, and the fit/notify cycle toward the PTY.
|
||||
- `boxDrawing.ts` draws Box Drawing (U+2500–U+257F), Block Elements (U+2580–U+259F) and Powerline arrows (U+E0B0–U+E0B3) procedurally to the exact cell; the renderer never sends those to the font.
|
||||
- `keyCodes.ts` mirrors the `GhosttyKey` enum of the pinned revision. `terminalLinks.ts` matches URLs across soft-wrapped rows. `fonts.ts` normalizes family lists for the canvas font shorthand and probes for monospace advances.
|
||||
- `vendor/` holds the reproducible artifact (`ghostty-vt.wasm`), the pinned upstream revision (`VERSION`) and Ghostty's license. `fonts/` vendors the symbols-only Nerd Font (MIT) so prompt glyphs render without a locally installed Nerd Font and without a CDN.
|
||||
|
||||
`components/terminal/TerminalViewport.tsx` is the only React consumer. React stays out of the render loop: the surface schedules its own frames.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The grid is measured after the faces that will render are loaded (`document.fonts.load` for every style plus the bundled symbols font). A face that finishes loading later triggers a re-measure through `loadingdone`. Never size the grid from a fallback face on purpose.
|
||||
- Generic keywords Chromium's canvas parser rejects (`ui-monospace`, `system-ui`) are stripped before any `context.font` assignment; an invalid shorthand silently no-ops and the grid would be measured with the previous font.
|
||||
- The canvas context is created with `willReadFrequently: true`, which pins it to the software rasterizer. Gecko otherwise picks acceleration per canvas, and its GPU text path on macOS skips CoreText smoothing: a terminal created after page load drew thin, pencil-like glyphs while the first one stayed on the software path (confirmed: `gfx.canvas.accelerated=false` in Zen removed the symptom). The backing store is also sized to the mount at DPR before the first paint, so the compositor never sees the default 300×150 store stretched.
|
||||
- Cell-filling symbols (borders, bars, block logos) are drawn by `boxDrawing.ts`, snapped to whole CSS pixels so neighbouring cells meet without seams. Fonts draw these only as tall as their em box, so at the 1.35 em line height every TUI border showed a strip of background between rows.
|
||||
- The PTY hears about a resize only after the grid settles (150 ms) and at most once per fit; `onResize` is the sole resize channel. The first successful fit always notifies, even at the construction size.
|
||||
- History replay (`resetAndWrite`) detaches the PTY writer so historical device queries never reach the live shell, and runs at the PTY size the history was drawn for when the caller passes one, so Ghostty reflows lines where the shell wrapped them.
|
||||
- A hidden surface (`setVisible(false)`) keeps parsing output and answering VT queries but schedules no frames, no cursor timer and no scrollbar work. Reveal repaints in full.
|
||||
- Touch hosts (`handleTouchPointer: false`) own scroll and long-press gestures through `scrollLines`, `selectWordAt` and `extendSelectionTo`; the surface ignores touch pointers and the compatibility mouse events that follow them so a tap does not summon the soft keyboard.
|
||||
- Every terminal frees its own handles on `dispose()`; the WebAssembly instance is shared and never torn down.
|
||||
|
||||
## Updating libghostty-vt
|
||||
|
||||
1. Put the new upstream commit hash in `vendor/VERSION`.
|
||||
2. Run `bun run --cwd packages/ui build:ghostty-wasm`. It downloads Zig 0.15.2 into `~/.cache/openchamber-ghostty`, clones Ghostty at the pin, builds `wasm32-freestanding`, replaces `vendor/ghostty-vt.wasm`, and prints the trampoline bytes for `runtime.ts` (they only change when the Zig source changes).
|
||||
3. Reconcile the ABI numbers in `core.ts` (`RENDER_DATA`, `ROW_DATA`, `CELL_DATA`, option ids in `setTheme`, `ghostty_terminal_get` ids) and `keyCodes.ts` against the headers of the new revision.
|
||||
4. `runtime.test.ts` fails when the artifact's embedded build metadata disagrees with `VERSION`.
|
||||
|
||||
On macOS 27 with Xcode 26+ SDKs the script works around a Zig 0.15.2 limitation: the SDK's `libSystem.tbd` lists only `arm64e-macos`, so the script builds a patched SDK root and shims `xcrun` for the duration of the build.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
bun test packages/ui/src/lib/ghostty packages/ui/src/components/terminal
|
||||
bun run --cwd packages/ui type-check
|
||||
```
|
||||
|
||||
The `core` and `runtime` tests run the real WebAssembly under bun; they cover reflow, palette, replay isolation and recycled-row cleanliness.
|
||||
@@ -0,0 +1,25 @@
|
||||
The libghostty-vt browser adapter in this directory (runtime.ts, core.ts,
|
||||
renderer.ts, surface.ts, keyCodes.ts and their tests) is adapted from T3 Code,
|
||||
https://github.com/pingdotgg/t3code, and carries its license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 T3 Tools Inc.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing';
|
||||
|
||||
type Call = readonly [string, ...number[]];
|
||||
|
||||
interface RecordingContext {
|
||||
readonly context: BoxDrawingContext;
|
||||
readonly calls: Call[];
|
||||
readonly fills: string[];
|
||||
}
|
||||
|
||||
function recordingContext(): RecordingContext {
|
||||
const calls: Call[] = [];
|
||||
const fills: string[] = [];
|
||||
const context: BoxDrawingContext = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt',
|
||||
fillRect: (x, y, w, h) => {
|
||||
calls.push(['fillRect', x, y, w, h]);
|
||||
fills.push(String(context.fillStyle));
|
||||
},
|
||||
beginPath: () => calls.push(['beginPath']),
|
||||
moveTo: (x, y) => calls.push(['moveTo', x, y]),
|
||||
lineTo: (x, y) => calls.push(['lineTo', x, y]),
|
||||
quadraticCurveTo: (cpx, cpy, x, y) => calls.push(['quadraticCurveTo', cpx, cpy, x, y]),
|
||||
closePath: () => calls.push(['closePath']),
|
||||
fill: () => calls.push(['fill']),
|
||||
stroke: () => calls.push(['stroke']),
|
||||
};
|
||||
return { context, calls, fills };
|
||||
}
|
||||
|
||||
const white = { r: 255, g: 255, b: 255 };
|
||||
// A 7.8 x 18 cell at a fractional x, like the real grid produces.
|
||||
const cell = { x: 4 + 7.8 * 3, y: 4 + 18 * 2, width: 7.8, height: 18 };
|
||||
|
||||
describe('isBoxDrawingText', () => {
|
||||
test('owns box drawing, block elements and powerline arrows only', () => {
|
||||
expect(isBoxDrawingText('─')).toBe(true);
|
||||
expect(isBoxDrawingText('╬')).toBe(true);
|
||||
expect(isBoxDrawingText('▀')).toBe(true);
|
||||
expect(isBoxDrawingText('░')).toBe(true);
|
||||
expect(isBoxDrawingText('')).toBe(true);
|
||||
expect(isBoxDrawingText('a')).toBe(false);
|
||||
expect(isBoxDrawingText('❯')).toBe(false);
|
||||
expect(isBoxDrawingText('')).toBe(false);
|
||||
expect(isBoxDrawingText('──')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawBoxDrawingGlyph', () => {
|
||||
test('fills a full block over the whole rounded cell so stacked rows touch', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
expect(drawBoxDrawingGlyph(context, '█', cell, white)).toBe(true);
|
||||
// x: 27.4 -> 27, right: 35.2 -> 35; y: 40, bottom: 58.
|
||||
expect(calls).toEqual([['fillRect', 27, 40, 8, 18]]);
|
||||
});
|
||||
|
||||
test('splits the upper and lower half blocks at the shared middle pixel', () => {
|
||||
const upper = recordingContext();
|
||||
const lower = recordingContext();
|
||||
drawBoxDrawingGlyph(upper.context, '▀', cell, white);
|
||||
drawBoxDrawingGlyph(lower.context, '▄', cell, white);
|
||||
expect(upper.calls).toEqual([['fillRect', 27, 40, 8, 9]]);
|
||||
expect(lower.calls).toEqual([['fillRect', 27, 49, 8, 9]]);
|
||||
});
|
||||
|
||||
test('shades with the foreground at partial alpha', () => {
|
||||
const { context, fills } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '▒', cell, white);
|
||||
expect(fills).toEqual(['rgba(255, 255, 255, 0.5)']);
|
||||
});
|
||||
|
||||
test('draws light lines edge to edge so neighbouring cells join without seams', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '─', cell, white);
|
||||
// Each arm reaches the far edge of the one-pixel center band.
|
||||
expect(calls).toEqual([
|
||||
['fillRect', 27, 49, 5, 1],
|
||||
['fillRect', 31, 49, 4, 1],
|
||||
]);
|
||||
const next = recordingContext();
|
||||
drawBoxDrawingGlyph(next.context, '─', { ...cell, x: cell.x + cell.width }, white);
|
||||
expect(next.calls[0]).toEqual(['fillRect', 35, 49, 5, 1]);
|
||||
});
|
||||
|
||||
test('closes a light corner at the junction square without a stub', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '┌', cell, white);
|
||||
expect(calls).toEqual([
|
||||
['fillRect', 31, 49, 4, 1],
|
||||
['fillRect', 31, 49, 1, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
test('draws heavy arms three strokes thick', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '━', cell, white);
|
||||
expect(calls).toEqual([
|
||||
['fillRect', 27, 48, 5, 3],
|
||||
['fillRect', 31, 48, 4, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test('nests the two lines of a double corner', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '╔', cell, white);
|
||||
// Right arm: outer (top) line from the outer vertical line, inner (bottom)
|
||||
// line from the inner vertical line. Down arm mirrors it.
|
||||
expect(calls).toEqual([
|
||||
['fillRect', 29, 47, 6, 1],
|
||||
['fillRect', 33, 51, 2, 1],
|
||||
['fillRect', 29, 47, 1, 11],
|
||||
['fillRect', 33, 51, 1, 7],
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps a double cross open in the middle', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '╬', cell, white);
|
||||
expect(calls).toEqual([
|
||||
['fillRect', 27, 47, 2, 1],
|
||||
['fillRect', 27, 51, 2, 1],
|
||||
['fillRect', 33, 47, 2, 1],
|
||||
['fillRect', 33, 51, 2, 1],
|
||||
['fillRect', 29, 40, 1, 7],
|
||||
['fillRect', 33, 40, 1, 7],
|
||||
['fillRect', 29, 51, 1, 7],
|
||||
['fillRect', 33, 51, 1, 7],
|
||||
]);
|
||||
});
|
||||
|
||||
test('strokes arcs, diagonals and outline arrows and fills solid arrows', () => {
|
||||
const arc = recordingContext();
|
||||
drawBoxDrawingGlyph(arc.context, '╭', cell, white);
|
||||
expect(arc.calls.map(([name]) => name)).toEqual(['beginPath', 'moveTo', 'lineTo', 'quadraticCurveTo', 'lineTo', 'stroke']);
|
||||
const diagonal = recordingContext();
|
||||
drawBoxDrawingGlyph(diagonal.context, '╳', cell, white);
|
||||
expect(diagonal.calls.filter(([name]) => name === 'moveTo')).toHaveLength(2);
|
||||
const solid = recordingContext();
|
||||
drawBoxDrawingGlyph(solid.context, '', cell, white);
|
||||
expect(solid.calls.at(-1)).toEqual(['fill']);
|
||||
const outline = recordingContext();
|
||||
drawBoxDrawingGlyph(outline.context, '', cell, white);
|
||||
expect(outline.calls.at(-1)).toEqual(['stroke']);
|
||||
});
|
||||
|
||||
test('splits dashed lines into their dash count', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
drawBoxDrawingGlyph(context, '┈', cell, white);
|
||||
expect(calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('leaves other text to the font', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
expect(drawBoxDrawingGlyph(context, 'a', cell, white)).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { GhosttyColor } from './core';
|
||||
|
||||
/**
|
||||
* Procedural glyphs for the cell-filling symbols TUI apps draw borders and
|
||||
* bars with: Box Drawing (U+2500–U+257F), Block Elements (U+2580–U+259F) and
|
||||
* the Powerline arrows (U+E0B0–U+E0B3). A font draws these only as tall as
|
||||
* its own em box, so at the terminal's 1.35 em line height every border and
|
||||
* every logo built from block characters shows a strip of background between
|
||||
* rows. Native terminals draw them to the exact cell instead; so does this.
|
||||
*/
|
||||
export interface BoxDrawingContext {
|
||||
fillStyle: string | CanvasGradient | CanvasPattern;
|
||||
strokeStyle: string | CanvasGradient | CanvasPattern;
|
||||
lineWidth: number;
|
||||
lineCap: CanvasLineCap;
|
||||
fillRect(x: number, y: number, w: number, h: number): void;
|
||||
beginPath(): void;
|
||||
moveTo(x: number, y: number): void;
|
||||
lineTo(x: number, y: number): void;
|
||||
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void;
|
||||
closePath(): void;
|
||||
fill(): void;
|
||||
stroke(): void;
|
||||
}
|
||||
|
||||
export interface BoxDrawingCell {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
const BOX_DRAWING_FIRST = 0x2500;
|
||||
const BOX_DRAWING_LAST = 0x257f;
|
||||
const BLOCK_FIRST = 0x2580;
|
||||
const BLOCK_LAST = 0x259f;
|
||||
const POWERLINE_FIRST = 0xe0b0;
|
||||
const POWERLINE_LAST = 0xe0b3;
|
||||
|
||||
/** Arm weights, one digit each for up, down, left, right; 0 is no arm. */
|
||||
const LIGHT = 1;
|
||||
const HEAVY = 2;
|
||||
const DOUBLE = 3;
|
||||
|
||||
// Up/Down/Left/Right weights for U+2500..U+257F. Dashed, arc and diagonal
|
||||
// forms carry their solid equivalent here and are special-cased when drawn.
|
||||
const BOX_ARMS =
|
||||
'0011 0022 1100 2200 0011 0022 1100 2200 0011 0022 1100 2200' + // 2500-250B lines and dashes
|
||||
' 0101 0102 0201 0202 0110 0120 0210 0220 1001 1002 2001 2002 1010 1020 2010 2020' + // 250C-251B corners
|
||||
' 1101 1102 2101 1201 2201 2102 1202 2202' + // 251C-2523 left tees
|
||||
' 1110 1120 2110 1210 2210 2120 1220 2220' + // 2524-252B right tees
|
||||
' 0111 0121 0112 0122 0211 0221 0212 0222' + // 252C-2533 top tees
|
||||
' 1011 1021 1012 1022 2011 2021 2012 2022' + // 2534-253B bottom tees
|
||||
' 1111 1121 1112 1122 2111 1211 2211 2121 2112 1221 1212 2122 1222 2221 2212 2222' + // 253C-254B crosses
|
||||
' 0011 0022 1100 2200' + // 254C-254F double dashes
|
||||
' 0033 3300' + // 2550-2551 double lines
|
||||
' 0103 0301 0303 0130 0310 0330 1003 3001 3003 1030 3010 3030' + // 2552-255D double corners
|
||||
' 1103 3301 3303 1130 3310 3330 0133 0311 0333 1033 3011 3033 1133 3311 3333' + // 255E-256C double tees and cross
|
||||
' 0101 0110 1010 1001' + // 256D-2570 arcs (drawn as curves)
|
||||
' 0000 0000 0000' + // 2571-2573 diagonals (drawn as lines)
|
||||
' 0010 1000 0001 0100 0020 2000 0002 0200' + // 2574-257B half lines
|
||||
' 0012 1200 0021 2100'; // 257C-257F mixed half lines
|
||||
|
||||
const BOX_ARM_TABLE = BOX_ARMS.split(' ');
|
||||
|
||||
const TRIPLE_DASH = new Set([0x2504, 0x2505, 0x2506, 0x2507]);
|
||||
const QUAD_DASH = new Set([0x2508, 0x2509, 0x250a, 0x250b]);
|
||||
const DOUBLE_DASH = new Set([0x254c, 0x254d, 0x254e, 0x254f]);
|
||||
|
||||
// Block elements as unit rectangles (left, top, width, height) of the cell.
|
||||
const BLOCK_RECTS = new Map<number, readonly (readonly [number, number, number, number])[]>([
|
||||
[0x2580, [[0, 0, 1, 1 / 2]]],
|
||||
[0x2581, [[0, 7 / 8, 1, 1 / 8]]],
|
||||
[0x2582, [[0, 6 / 8, 1, 2 / 8]]],
|
||||
[0x2583, [[0, 5 / 8, 1, 3 / 8]]],
|
||||
[0x2584, [[0, 1 / 2, 1, 1 / 2]]],
|
||||
[0x2585, [[0, 3 / 8, 1, 5 / 8]]],
|
||||
[0x2586, [[0, 2 / 8, 1, 6 / 8]]],
|
||||
[0x2587, [[0, 1 / 8, 1, 7 / 8]]],
|
||||
[0x2588, [[0, 0, 1, 1]]],
|
||||
[0x2589, [[0, 0, 7 / 8, 1]]],
|
||||
[0x258a, [[0, 0, 6 / 8, 1]]],
|
||||
[0x258b, [[0, 0, 5 / 8, 1]]],
|
||||
[0x258c, [[0, 0, 1 / 2, 1]]],
|
||||
[0x258d, [[0, 0, 3 / 8, 1]]],
|
||||
[0x258e, [[0, 0, 2 / 8, 1]]],
|
||||
[0x258f, [[0, 0, 1 / 8, 1]]],
|
||||
[0x2590, [[1 / 2, 0, 1 / 2, 1]]],
|
||||
[0x2594, [[0, 0, 1, 1 / 8]]],
|
||||
[0x2595, [[7 / 8, 0, 1 / 8, 1]]],
|
||||
[0x2596, [[0, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x2597, [[1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x2598, [[0, 0, 1 / 2, 1 / 2]]],
|
||||
[0x2599, [[0, 0, 1 / 2, 1], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x259a, [[0, 0, 1 / 2, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x259b, [[0, 0, 1, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x259c, [[0, 0, 1, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x259d, [[1 / 2, 0, 1 / 2, 1 / 2]]],
|
||||
[0x259e, [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]]],
|
||||
[0x259f, [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1, 1 / 2]]],
|
||||
]);
|
||||
|
||||
const SHADE_ALPHA = new Map([[0x2591, 0.25], [0x2592, 0.5], [0x2593, 0.75]]);
|
||||
|
||||
/** Whether a cell's text is a single symbol this module draws instead of the font. */
|
||||
export function isBoxDrawingText(text: string): boolean {
|
||||
if (text.length === 0 || text.length > 2) return false;
|
||||
const code = text.codePointAt(0);
|
||||
if (code === undefined || String.fromCodePoint(code) !== text) return false;
|
||||
return (
|
||||
(code >= BOX_DRAWING_FIRST && code <= BOX_DRAWING_LAST) ||
|
||||
(code >= BLOCK_FIRST && code <= BLOCK_LAST) ||
|
||||
(code >= POWERLINE_FIRST && code <= POWERLINE_LAST)
|
||||
);
|
||||
}
|
||||
|
||||
function rgba(color: GhosttyColor, alpha: number): string {
|
||||
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
interface CellGeometry {
|
||||
readonly left: number;
|
||||
readonly right: number;
|
||||
readonly top: number;
|
||||
readonly bottom: number;
|
||||
readonly centerX: number;
|
||||
readonly centerY: number;
|
||||
/** Light stroke thickness. */
|
||||
readonly stroke: number;
|
||||
/** Half-distance between the two lines of a double stroke. */
|
||||
readonly gap: number;
|
||||
}
|
||||
|
||||
// Everything snaps to whole CSS pixels. Neighbouring cells round the same
|
||||
// shared edge to the same value, so borders meet without seams, and a whole
|
||||
// pixel stays crisp at every integer device pixel ratio.
|
||||
function cellGeometry(cell: BoxDrawingCell): CellGeometry {
|
||||
const left = Math.round(cell.x);
|
||||
const right = Math.round(cell.x + cell.width);
|
||||
const top = Math.round(cell.y);
|
||||
const bottom = Math.round(cell.y + cell.height);
|
||||
const stroke = Math.max(1, Math.round(cell.width / 8));
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
centerX: Math.round(cell.x + cell.width / 2),
|
||||
centerY: Math.round(cell.y + cell.height / 2),
|
||||
stroke,
|
||||
gap: stroke + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function armThickness(weight: number, stroke: number): number {
|
||||
return weight === HEAVY ? stroke * 3 : stroke;
|
||||
}
|
||||
|
||||
/** Fill a horizontal band [x0, x1) centered on y with the given thickness. */
|
||||
function hBand(context: BoxDrawingContext, x0: number, x1: number, y: number, thickness: number): void {
|
||||
if (x1 <= x0) return;
|
||||
context.fillRect(x0, y - Math.floor(thickness / 2), x1 - x0, thickness);
|
||||
}
|
||||
|
||||
function vBand(context: BoxDrawingContext, y0: number, y1: number, x: number, thickness: number): void {
|
||||
if (y1 <= y0) return;
|
||||
context.fillRect(x - Math.floor(thickness / 2), y0, thickness, y1 - y0);
|
||||
}
|
||||
|
||||
function drawDashes(
|
||||
context: BoxDrawingContext,
|
||||
geometry: CellGeometry,
|
||||
horizontal: boolean,
|
||||
weight: number,
|
||||
count: number,
|
||||
): void {
|
||||
const thickness = armThickness(weight, geometry.stroke);
|
||||
const start = horizontal ? geometry.left : geometry.top;
|
||||
const end = horizontal ? geometry.right : geometry.bottom;
|
||||
const gapSize = geometry.stroke;
|
||||
const span = end - start;
|
||||
const dash = Math.max(1, Math.floor((span - gapSize * (count - 1)) / count));
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const from = start + index * (dash + gapSize);
|
||||
const to = index === count - 1 ? end : from + dash;
|
||||
if (horizontal) hBand(context, from, to, geometry.centerY, thickness);
|
||||
else vBand(context, from, to, geometry.centerX, thickness);
|
||||
}
|
||||
}
|
||||
|
||||
function drawArms(context: BoxDrawingContext, geometry: CellGeometry, arms: string): void {
|
||||
const up = Number(arms[0]);
|
||||
const down = Number(arms[1]);
|
||||
const left = Number(arms[2]);
|
||||
const right = Number(arms[3]);
|
||||
const { centerX, centerY, stroke, gap } = geometry;
|
||||
const single = (weight: number) => weight === LIGHT || weight === HEAVY;
|
||||
// A single-weight arm runs to the far edge of the thickest crossing arm's
|
||||
// band, so corners and tees fill their junction square exactly, without a
|
||||
// hole and without a stub past the perpendicular line.
|
||||
const crossV = Math.max(armThickness(up, stroke), armThickness(down, stroke), stroke);
|
||||
const crossH = Math.max(armThickness(left, stroke), armThickness(right, stroke), stroke);
|
||||
const bandLeft = centerX - Math.floor(crossV / 2);
|
||||
const bandRight = bandLeft + crossV;
|
||||
const bandTop = centerY - Math.floor(crossH / 2);
|
||||
const bandBottom = bandTop + crossH;
|
||||
const verticalDouble = up === DOUBLE || down === DOUBLE;
|
||||
const horizontalDouble = left === DOUBLE || right === DOUBLE;
|
||||
|
||||
if (single(left)) {
|
||||
hBand(context, geometry.left, verticalDouble ? centerX - gap : bandRight, centerY, armThickness(left, stroke));
|
||||
}
|
||||
if (single(right)) {
|
||||
hBand(context, verticalDouble ? centerX + gap : bandLeft, geometry.right, centerY, armThickness(right, stroke));
|
||||
}
|
||||
if (single(up)) {
|
||||
vBand(context, geometry.top, horizontalDouble ? centerY - gap : bandBottom, centerX, armThickness(up, stroke));
|
||||
}
|
||||
if (single(down)) {
|
||||
vBand(context, horizontalDouble ? centerY + gap : bandTop, geometry.bottom, centerX, armThickness(down, stroke));
|
||||
}
|
||||
|
||||
// A double arm is two light lines. Where a line meets the perpendicular arm
|
||||
// on its own side it stops at that arm's matching line (a double arm), at
|
||||
// the center (a single arm), or crosses to the far line to close a corner
|
||||
// or run straight through (no arm). `sign` is +1 toward the far side.
|
||||
const stopX = (perpendicular: number, sign: 1 | -1) =>
|
||||
perpendicular === DOUBLE ? centerX - sign * gap : single(perpendicular) ? centerX : centerX + sign * gap;
|
||||
const stopY = (perpendicular: number, sign: 1 | -1) =>
|
||||
perpendicular === DOUBLE ? centerY - sign * gap : single(perpendicular) ? centerY : centerY + sign * gap;
|
||||
if (left === DOUBLE) {
|
||||
hBand(context, geometry.left, stopX(up, 1), centerY - gap, stroke);
|
||||
hBand(context, geometry.left, stopX(down, 1), centerY + gap, stroke);
|
||||
}
|
||||
if (right === DOUBLE) {
|
||||
hBand(context, stopX(up, -1), geometry.right, centerY - gap, stroke);
|
||||
hBand(context, stopX(down, -1), geometry.right, centerY + gap, stroke);
|
||||
}
|
||||
if (up === DOUBLE) {
|
||||
vBand(context, geometry.top, stopY(left, 1), centerX - gap, stroke);
|
||||
vBand(context, geometry.top, stopY(right, 1), centerX + gap, stroke);
|
||||
}
|
||||
if (down === DOUBLE) {
|
||||
vBand(context, stopY(left, -1), geometry.bottom, centerX - gap, stroke);
|
||||
vBand(context, stopY(right, -1), geometry.bottom, centerX + gap, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
function drawArc(context: BoxDrawingContext, geometry: CellGeometry, code: number): void {
|
||||
const { centerX, centerY, stroke } = geometry;
|
||||
// 256D ╭ down+right, 256E ╮ down+left, 256F ╯ up+left, 2570 ╰ up+right
|
||||
const toRight = code === 0x256d || code === 0x2570;
|
||||
const toDown = code === 0x256d || code === 0x256e;
|
||||
const endX = toRight ? geometry.right : geometry.left;
|
||||
const endY = toDown ? geometry.bottom : geometry.top;
|
||||
const radius = Math.min(Math.abs(endX - centerX), Math.abs(endY - centerY));
|
||||
const dirX = toRight ? 1 : -1;
|
||||
const dirY = toDown ? 1 : -1;
|
||||
// An odd stroke sits on pixel centers, matching the fillRect bands of the
|
||||
// straight forms so a curve continues a line without a half-pixel step.
|
||||
const align = (stroke % 2) / 2;
|
||||
const cx = centerX + align;
|
||||
const cy = centerY + align;
|
||||
context.beginPath();
|
||||
context.moveTo(endX, cy);
|
||||
context.lineTo(cx + dirX * radius, cy);
|
||||
context.quadraticCurveTo(cx, cy, cx, cy + dirY * radius);
|
||||
context.lineTo(cx, endY);
|
||||
context.lineWidth = stroke;
|
||||
context.lineCap = 'butt';
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawDiagonal(context: BoxDrawingContext, geometry: CellGeometry, code: number): void {
|
||||
context.lineWidth = geometry.stroke;
|
||||
context.lineCap = 'butt';
|
||||
context.beginPath();
|
||||
if (code === 0x2571 || code === 0x2573) {
|
||||
context.moveTo(geometry.right, geometry.top);
|
||||
context.lineTo(geometry.left, geometry.bottom);
|
||||
}
|
||||
if (code === 0x2572 || code === 0x2573) {
|
||||
context.moveTo(geometry.left, geometry.top);
|
||||
context.lineTo(geometry.right, geometry.bottom);
|
||||
}
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawPowerline(context: BoxDrawingContext, geometry: CellGeometry, code: number): void {
|
||||
const { left, right, top, bottom, centerY } = geometry;
|
||||
const pointsRight = code === 0xe0b0 || code === 0xe0b1;
|
||||
const tip = pointsRight ? right : left;
|
||||
const base = pointsRight ? left : right;
|
||||
context.beginPath();
|
||||
context.moveTo(base, top);
|
||||
context.lineTo(tip, centerY);
|
||||
context.lineTo(base, bottom);
|
||||
if (code === 0xe0b0 || code === 0xe0b2) {
|
||||
context.closePath();
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
context.lineWidth = geometry.stroke;
|
||||
context.lineCap = 'butt';
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one symbol into its cell. Returns false when the code point is not a
|
||||
* symbol this module owns, so the caller falls back to the font.
|
||||
*/
|
||||
export function drawBoxDrawingGlyph(
|
||||
context: BoxDrawingContext,
|
||||
text: string,
|
||||
cell: BoxDrawingCell,
|
||||
color: GhosttyColor,
|
||||
): boolean {
|
||||
if (!isBoxDrawingText(text)) return false;
|
||||
const code = text.codePointAt(0) ?? 0;
|
||||
const geometry = cellGeometry(cell);
|
||||
const solid = rgba(color, 1);
|
||||
context.fillStyle = solid;
|
||||
context.strokeStyle = solid;
|
||||
|
||||
if (code >= BLOCK_FIRST && code <= BLOCK_LAST) {
|
||||
const shade = SHADE_ALPHA.get(code);
|
||||
if (shade !== undefined) {
|
||||
context.fillStyle = rgba(color, shade);
|
||||
context.fillRect(geometry.left, geometry.top, geometry.right - geometry.left, geometry.bottom - geometry.top);
|
||||
return true;
|
||||
}
|
||||
const width = geometry.right - geometry.left;
|
||||
const height = geometry.bottom - geometry.top;
|
||||
for (const [x, y, w, h] of BLOCK_RECTS.get(code) ?? []) {
|
||||
// Edges of eighths snap independently so stacked bars still tile.
|
||||
const x0 = geometry.left + Math.round(x * width);
|
||||
const x1 = geometry.left + Math.round((x + w) * width);
|
||||
const y0 = geometry.top + Math.round(y * height);
|
||||
const y1 = geometry.top + Math.round((y + h) * height);
|
||||
context.fillRect(x0, y0, Math.max(1, x1 - x0), Math.max(1, y1 - y0));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (code >= POWERLINE_FIRST && code <= POWERLINE_LAST) {
|
||||
drawPowerline(context, geometry, code);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (code >= 0x256d && code <= 0x2570) {
|
||||
drawArc(context, geometry, code);
|
||||
return true;
|
||||
}
|
||||
if (code >= 0x2571 && code <= 0x2573) {
|
||||
drawDiagonal(context, geometry, code);
|
||||
return true;
|
||||
}
|
||||
const arms = BOX_ARM_TABLE[code - BOX_DRAWING_FIRST] ?? '0000';
|
||||
const dashCount = TRIPLE_DASH.has(code) ? 3 : QUAD_DASH.has(code) ? 4 : DOUBLE_DASH.has(code) ? 2 : 0;
|
||||
if (dashCount > 0) {
|
||||
const horizontal = arms[2] !== '0';
|
||||
drawDashes(context, geometry, horizontal, Number(horizontal ? arms[2] : arms[0]), dashCount);
|
||||
return true;
|
||||
}
|
||||
drawArms(context, geometry, arms);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText, ghosttyPaletteBytes, type GhosttyColor } from './core';
|
||||
import { loadGhosttyRuntime } from './runtime';
|
||||
|
||||
const WHITE: GhosttyColor = { r: 255, g: 255, b: 255 };
|
||||
const BLACK: GhosttyColor = { r: 0, g: 0, b: 0 };
|
||||
|
||||
function codepointView(codepoints: ReadonlyArray<number>): DataView {
|
||||
const view = new DataView(new ArrayBuffer(codepoints.length * 4));
|
||||
codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true));
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('ghosttyCellText', () => {
|
||||
test('converts oversized grapheme clusters without hitting engine 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);
|
||||
expect(text.length).toBe(graphemeLength);
|
||||
expect(text.codePointAt(0)).toBe('a'.codePointAt(0));
|
||||
expect(text.codePointAt(graphemeLength - 1)).toBe(0x301);
|
||||
});
|
||||
|
||||
test('converts small clusters including astral codepoints', () => {
|
||||
expect([...ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2)]).toEqual(['\u{1F642}', '\u{20E3}']);
|
||||
expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe('🙂');
|
||||
expect(ghosttyCellText(codepointView([]), 0)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ghosttyPaletteBytes', () => {
|
||||
test('places the theme ANSI colors first and keeps the xterm cube and gray ramp', () => {
|
||||
const ansi = Array.from({ length: 16 }, (_, index) => ({ r: index, g: index * 2, b: index * 3 }));
|
||||
const bytes = ghosttyPaletteBytes(ansi);
|
||||
expect(bytes.length).toBe(768);
|
||||
expect([...bytes.subarray(15 * 3, 16 * 3)]).toEqual([15, 30, 45]);
|
||||
// Index 196 is pure red in the 6x6x6 cube; 232 is the darkest gray.
|
||||
expect([...bytes.subarray(196 * 3, 197 * 3)]).toEqual([255, 0, 0]);
|
||||
expect([...bytes.subarray(232 * 3, 233 * 3)]).toEqual([8, 8, 8]);
|
||||
expect([...bytes.subarray(255 * 3, 256 * 3)]).toEqual([238, 238, 238]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GhosttyTerminalCore', () => {
|
||||
const cores = new Set<GhosttyTerminalCore>();
|
||||
|
||||
async function createCore(onData: (data: string) => void = () => {}, palette?: GhosttyColor[]) {
|
||||
const core = await GhosttyTerminalCore.create(12, 3, 8, 16, {
|
||||
foreground: WHITE,
|
||||
background: BLACK,
|
||||
cursor: WHITE,
|
||||
palette,
|
||||
}, onData);
|
||||
cores.add(core);
|
||||
return core;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const core of cores) core.dispose();
|
||||
cores.clear();
|
||||
});
|
||||
|
||||
test('preserves styles, wide cells, and selection after shared memory grows', async () => {
|
||||
const core = await createCore();
|
||||
const runtime = await loadGhosttyRuntime();
|
||||
const grapheme = `e${'́'.repeat(64)}`;
|
||||
core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m界🙂`);
|
||||
const cells = core.snapshot().rowData[0]!.cells;
|
||||
expect(cells[0]).toEqual({
|
||||
text: grapheme,
|
||||
wide: 0,
|
||||
foreground: { r: 123, g: 45, b: 67 },
|
||||
background: { r: 9, g: 8, b: 7 },
|
||||
bold: true,
|
||||
italic: true,
|
||||
invisible: true,
|
||||
strikethrough: true,
|
||||
overline: true,
|
||||
underline: true,
|
||||
selected: false,
|
||||
});
|
||||
expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([
|
||||
{ text: '界', wide: 0 },
|
||||
{ text: '', wide: GHOSTTY_CELL_WIDE.spacerTail },
|
||||
{ text: '🙂', wide: 0 },
|
||||
{ text: '', wide: GHOSTTY_CELL_WIDE.spacerTail },
|
||||
]);
|
||||
|
||||
runtime.memory.grow(1);
|
||||
core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 });
|
||||
expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0]!, selected: true });
|
||||
core.clearSelection();
|
||||
expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]!);
|
||||
});
|
||||
|
||||
test('renders ANSI colors from the theme palette', async () => {
|
||||
const palette = Array.from({ length: 16 }, (_, index) => ({ r: 10 + index, g: 20, b: 30 }));
|
||||
const core = await createCore(() => {}, palette);
|
||||
core.write('\x1b[31mred\x1b[0m \x1b[94mblue');
|
||||
const cells = core.snapshot().rowData[0]!.cells;
|
||||
expect(cells[0]!.foreground).toEqual({ r: 11, g: 20, b: 30 });
|
||||
expect(cells[4]!.foreground).toEqual({ r: 22, g: 20, b: 30 });
|
||||
// Indices past the theme keep the standard table.
|
||||
core.write('\x1b[38;5;196mX');
|
||||
expect(core.snapshot().rowData[0]!.cells[8]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
test('answers device queries through the PTY writer but not during history replay', async () => {
|
||||
const replies: string[] = [];
|
||||
const core = await createCore((data) => replies.push(data));
|
||||
core.write('\x1b[5n');
|
||||
expect(replies).toEqual(['\x1b[0n']);
|
||||
replies.length = 0;
|
||||
|
||||
core.resetAndWrite('history\x1b[5n');
|
||||
expect(replies).toEqual([]);
|
||||
expect(core.snapshot().rowData[0]!.text).toBe('history');
|
||||
|
||||
core.write('\x1b[5n');
|
||||
expect(replies).toEqual(['\x1b[0n']);
|
||||
});
|
||||
|
||||
test('a fresh terminal after disposing a scrolled one shows none of its rows', async () => {
|
||||
const first = await createCore();
|
||||
first.write(Array.from({ length: 200 }, (_, index) => `leak-${index}\r\n`).join(''));
|
||||
first.snapshot();
|
||||
first.dispose();
|
||||
cores.delete(first);
|
||||
|
||||
const second = await createCore();
|
||||
second.write('fresh\r\n'.repeat(4));
|
||||
const rows = second.snapshot().rowData.map((row) => row.text);
|
||||
expect(rows.some((text) => text.includes('leak-'))).toBe(false);
|
||||
expect(rows[0]).toBe('fresh');
|
||||
});
|
||||
|
||||
test('reflows history written at a wider size back to the fitted grid', async () => {
|
||||
const core = await createCore();
|
||||
core.resize(40, 3, 8, 16);
|
||||
core.resetAndWrite(`${'x'.repeat(30)}\r\nprompt> `);
|
||||
core.resize(12, 3, 8, 16);
|
||||
// 30 columns wrap into 12 + 12 + 6; the first wrapped row scrolls out of a 3-row viewport.
|
||||
expect(core.snapshot().rowData.map((row) => row.text)).toEqual(['x'.repeat(12), 'x'.repeat(6), 'prompt>']);
|
||||
});
|
||||
|
||||
test('encodes bracketed paste only when the terminal asked for it', async () => {
|
||||
const core = await createCore();
|
||||
expect(core.encodePaste('hello')).toBe('hello');
|
||||
core.write('\x1b[?2004h');
|
||||
expect(core.encodePaste('hello')).toBe('\x1b[200~hello\x1b[201~');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { areFontAdvancesMonospace, canvasFontFamilies, quoteFontFamilyName } from './fonts';
|
||||
|
||||
describe('canvasFontFamilies', () => {
|
||||
test('quotes names the canvas shorthand would reject and drops engine-specific generics', () => {
|
||||
expect(canvasFontFamilies('ui-monospace, JetBrains Mono, "Fira Code", Menlo, monospace'))
|
||||
.toBe('"JetBrains Mono", "Fira Code", Menlo, monospace');
|
||||
expect(canvasFontFamilies('ui-monospace')).toBeNull();
|
||||
expect(canvasFontFamilies('')).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps already quoted and single-ident names as they are', () => {
|
||||
expect(quoteFontFamilyName('"3270 Nerd Font"')).toBe('"3270 Nerd Font"');
|
||||
expect(quoteFontFamilyName('Menlo')).toBe('Menlo');
|
||||
expect(quoteFontFamilyName('M+ 1m')).toBe('"M+ 1m"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('areFontAdvancesMonospace', () => {
|
||||
test('accepts equal advances and treats unmeasurable input as monospace', () => {
|
||||
expect(areFontAdvancesMonospace([7.2, 7.2, 7.2])).toBe(true);
|
||||
expect(areFontAdvancesMonospace([7.2, 9.1, 7.2])).toBe(false);
|
||||
expect(areFontAdvancesMonospace([])).toBe(true);
|
||||
expect(areFontAdvancesMonospace([0, 0])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
|
||||
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;
|
||||
// Generic keywords the canvas font shorthand parser does not accept in every
|
||||
// engine (Chromium rejects ui-monospace outright, which silently voids the
|
||||
// whole assignment). The concrete platform faces cover the same intent.
|
||||
const UNSUPPORTED_CANVAS_GENERICS = /^(ui-monospace|ui-sans-serif|ui-serif|system-ui)$/i;
|
||||
|
||||
export function quoteFontFamilyName(name: string): string {
|
||||
const bare = name.trim();
|
||||
if (bare.length === 0) return '';
|
||||
// Already quoted, or a single ident that needs no quoting.
|
||||
if (/^(['"]).*\1$/.test(bare)) return bare;
|
||||
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;
|
||||
return `"${bare.replaceAll('"', '')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a family list into a canvas-safe CSS font-family list, or null
|
||||
* when nothing usable remains. Quotes names the shorthand would reject and
|
||||
* drops generics that only some engines know.
|
||||
*/
|
||||
export function canvasFontFamilies(input: string): string | null {
|
||||
const families = input
|
||||
.split(',')
|
||||
.map(quoteFontFamilyName)
|
||||
.filter((name) => name.length > 0 && !UNSUPPORTED_CANVAS_GENERICS.test(name));
|
||||
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);
|
||||
}
|
||||
|
||||
let fontProbeContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
/**
|
||||
* Whether a family renders every character on the same advance. The cell grid
|
||||
* requires this: a proportional face draws its text narrower than its own
|
||||
* cells and strands the cursor.
|
||||
*/
|
||||
export function isMonospaceFamily(family: string): boolean {
|
||||
const families = canvasFontFamilies(family);
|
||||
if (families === null) return true;
|
||||
try {
|
||||
if (fontProbeContext === undefined) {
|
||||
fontProbeContext = document.createElement('canvas').getContext('2d');
|
||||
}
|
||||
if (fontProbeContext === null) return true;
|
||||
const context = fontProbeContext;
|
||||
// Fall back to a generic mono so an absent face measures as monospace and
|
||||
// is left for the normal fallback chain to resolve.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from './keyCodes';
|
||||
|
||||
describe('ghosttyKeyForCode', () => {
|
||||
test('keeps the tail of the pinned Ghostty key enum in order', () => {
|
||||
expect(ghosttyKeyForCode('F25')).toBe(ghosttyKeyForCode('F24') + 1);
|
||||
expect(ghosttyKeyForCode('PrintScreen')).toBe(ghosttyKeyForCode('FnLock') + 1);
|
||||
expect(ghosttyKeyForCode('Pause')).toBe(ghosttyKeyForCode('ScrollLock') + 1);
|
||||
expect(ghosttyKeyForCode('Paste')).toBe(ghosttyKeyForCode('Cut') + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ghosttyConsumedMods', () => {
|
||||
const shifted = { altKey: false, ctrlKey: false, key: '@', metaKey: false, shiftKey: true };
|
||||
|
||||
test('only consumes a lone Shift producing a character', () => {
|
||||
expect(ghosttyConsumedMods(shifted)).toBe(1);
|
||||
expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0);
|
||||
expect(ghosttyConsumedMods({ ...shifted, key: 'Tab' })).toBe(0);
|
||||
// Deliberate: Shift+Space collapses to Space so it still types one.
|
||||
expect(ghosttyConsumedMods({ ...shifted, key: ' ' })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ghosttyUnshiftedCodepoint', () => {
|
||||
test('provides the logical base character for Kitty keyboard encoding', () => {
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'c', shiftKey: false })).toBe(
|
||||
'c'.codePointAt(0),
|
||||
);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'C', shiftKey: true })).toBe(
|
||||
'c'.codePointAt(0),
|
||||
);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '!', shiftKey: true })).toBe(
|
||||
'1'.codePointAt(0),
|
||||
);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'Slash', key: '?', shiftKey: true })).toBe(
|
||||
'/'.codePointAt(0),
|
||||
);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '&', shiftKey: false })).toBe(
|
||||
'&'.codePointAt(0),
|
||||
);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'Enter', key: 'Enter', shiftKey: false })).toBe(0);
|
||||
});
|
||||
|
||||
test('reports unknown instead of the shifted character without layout data', () => {
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'Digit7', key: '/', shiftKey: true })).toBe(0);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'KeyD', key: 'Д', shiftKey: true })).toBe(
|
||||
'д'.codePointAt(0),
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers the active browser layout over US physical key positions', () => {
|
||||
const layoutMap = new Map([
|
||||
['Digit1', '&'],
|
||||
['KeyC', 'j'],
|
||||
]);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '1', shiftKey: true }, layoutMap)).toBe(
|
||||
'&'.codePointAt(0),
|
||||
);
|
||||
expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'J', shiftKey: true }, layoutMap)).toBe(
|
||||
'j'.codePointAt(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
|
||||
// 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<string, number>(
|
||||
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<string, string>([
|
||||
['!', '1'],
|
||||
['@', '2'],
|
||||
['#', '3'],
|
||||
['$', '4'],
|
||||
['%', '5'],
|
||||
['^', '6'],
|
||||
['&', '7'],
|
||||
['*', '8'],
|
||||
['(', '9'],
|
||||
[')', '0'],
|
||||
['~', '`'],
|
||||
['_', '-'],
|
||||
['+', '='],
|
||||
['{', '['],
|
||||
['}', ']'],
|
||||
['|', '\\'],
|
||||
[':', ';'],
|
||||
['"', "'"],
|
||||
['<', ','],
|
||||
['>', '.'],
|
||||
['?', '/'],
|
||||
]);
|
||||
|
||||
let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined;
|
||||
|
||||
export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap | undefined> {
|
||||
if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise;
|
||||
// SAFETY: navigator.keyboard is Chromium-only and absent from lib.dom; the
|
||||
// optional field keeps every other engine on the undefined branch.
|
||||
const keyboard = (
|
||||
globalThis.navigator as Navigator & {
|
||||
readonly keyboard?: { getLayoutMap(): Promise<GhosttyKeyboardLayoutMap> };
|
||||
} | undefined
|
||||
)?.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<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
): number {
|
||||
if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0;
|
||||
return [...event.key].length === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
export function ghosttyUnshiftedCodepoint(
|
||||
event: Pick<KeyboardEvent, 'code' | 'key' | 'shiftKey'>,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from './core';
|
||||
import {
|
||||
ghosttyTextRunEnd,
|
||||
measureGhosttyCell,
|
||||
renderGhosttySnapshot,
|
||||
terminalGridSize,
|
||||
} from './renderer';
|
||||
|
||||
const cell = (text: string, wide = 0): GhosttyCell => ({
|
||||
text,
|
||||
wide,
|
||||
foreground: { r: 255, g: 255, b: 255 },
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
bold: false,
|
||||
italic: false,
|
||||
invisible: false,
|
||||
strikethrough: false,
|
||||
overline: false,
|
||||
underline: false,
|
||||
selected: false,
|
||||
});
|
||||
|
||||
describe('terminalGridSize', () => {
|
||||
test("matches the mobile renderer's cell-and-padding sizing model", () => {
|
||||
expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({
|
||||
cols: 80,
|
||||
rows: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('never sends an invalid zero-sized terminal to libghostty', () => {
|
||||
expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('measureGhosttyCell', () => {
|
||||
test('uses descender-aware metrics and the mobile terminal line-height', () => {
|
||||
const measureText = (text: string) =>
|
||||
text === 'M'
|
||||
? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 }
|
||||
: { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 };
|
||||
const context = { font: '', measureText };
|
||||
|
||||
expect(measureGhosttyCell(context, 12, 'monospace')).toEqual({
|
||||
width: 7.2,
|
||||
height: 16,
|
||||
baseline: 11,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ghosttyTextRunEnd', () => {
|
||||
test('includes wide spacer tails in the visual clip without rendering spaces', () => {
|
||||
const cells = [
|
||||
cell('界', GHOSTTY_CELL_WIDE.wide),
|
||||
cell('', GHOSTTY_CELL_WIDE.spacerTail),
|
||||
cell('🙂', GHOSTTY_CELL_WIDE.wide),
|
||||
cell('', GHOSTTY_CELL_WIDE.spacerTail),
|
||||
cell(''),
|
||||
];
|
||||
expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderGhosttySnapshot', () => {
|
||||
test('underlines every cell in a hovered wrapped link', () => {
|
||||
const fillRectCalls: number[][] = [];
|
||||
const context = {
|
||||
canvas: { width: 200, height: 80 },
|
||||
beginPath: () => {},
|
||||
clip: () => {},
|
||||
fillRect: (...args: number[]) => fillRectCalls.push(args),
|
||||
fillText: () => {},
|
||||
rect: () => {},
|
||||
resetTransform: () => {},
|
||||
restore: () => {},
|
||||
save: () => {},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textBaseline: 'alphabetic' as const,
|
||||
strokeRect: () => {},
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt' as const,
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
quadraticCurveTo: () => {},
|
||||
closePath: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {},
|
||||
};
|
||||
const snapshot: GhosttySnapshot = {
|
||||
cols: 4,
|
||||
rows: 2,
|
||||
foreground: { r: 255, g: 255, b: 255 },
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
cursor: { r: 255, g: 255, b: 255 },
|
||||
cursorX: -1,
|
||||
cursorY: -1,
|
||||
cursorVisible: false,
|
||||
cursorBlinking: false,
|
||||
cursorStyle: 1,
|
||||
dirtyRows: new Set([0, 1]),
|
||||
rowData: [0, 1].map(() => ({
|
||||
cells: [cell('a'), cell('b'), cell('c'), cell('d')],
|
||||
text: 'abcd',
|
||||
isWrapContinuation: false,
|
||||
wrapsToNext: false,
|
||||
})),
|
||||
};
|
||||
|
||||
renderGhosttySnapshot({
|
||||
context,
|
||||
snapshot,
|
||||
metrics: { width: 10, height: 20, baseline: 15 },
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
padding: 4,
|
||||
forceFull: false,
|
||||
cursorOn: false,
|
||||
hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } },
|
||||
});
|
||||
|
||||
expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([
|
||||
[24, 22, 10, 1],
|
||||
[34, 22, 10, 1],
|
||||
[4, 42, 10, 1],
|
||||
[14, 42, 10, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('constrains text runs and cursor glyphs to their terminal cells', () => {
|
||||
const fillTextCalls: unknown[][] = [];
|
||||
const context = {
|
||||
canvas: { width: 200, height: 40 },
|
||||
beginPath: () => {},
|
||||
clip: () => {},
|
||||
fillRect: () => {},
|
||||
fillText: (...args: unknown[]) => fillTextCalls.push(args),
|
||||
rect: () => {},
|
||||
resetTransform: () => {},
|
||||
restore: () => {},
|
||||
save: () => {},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textBaseline: 'alphabetic' as const,
|
||||
strokeRect: () => {},
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt' as const,
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
quadraticCurveTo: () => {},
|
||||
closePath: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {},
|
||||
};
|
||||
const cells = [cell('a'), cell('b'), cell('x')];
|
||||
const snapshot: GhosttySnapshot = {
|
||||
cols: 3,
|
||||
rows: 1,
|
||||
foreground: { r: 255, g: 255, b: 255 },
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
cursor: { r: 255, g: 255, b: 255 },
|
||||
cursorX: 2,
|
||||
cursorY: 0,
|
||||
cursorVisible: true,
|
||||
cursorBlinking: false,
|
||||
cursorStyle: 1,
|
||||
dirtyRows: new Set([0]),
|
||||
rowData: [{ cells, text: 'abx', isWrapContinuation: false, wrapsToNext: false }],
|
||||
};
|
||||
|
||||
renderGhosttySnapshot({
|
||||
context,
|
||||
snapshot,
|
||||
metrics: { width: 7.2, height: 16, baseline: 11 },
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
padding: 4,
|
||||
forceFull: false,
|
||||
cursorOn: true,
|
||||
});
|
||||
|
||||
expect(fillTextCalls).toEqual([
|
||||
['abx', 4, 15, 21.6],
|
||||
['x', 18.4, 15, 7.2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('repaints the cell without an overlay during the blink off phase', () => {
|
||||
const fillTextCalls: unknown[][] = [];
|
||||
const context = {
|
||||
canvas: { width: 200, height: 40 },
|
||||
beginPath: () => {},
|
||||
clip: () => {},
|
||||
fillRect: () => {},
|
||||
fillText: (...args: unknown[]) => fillTextCalls.push(args),
|
||||
rect: () => {},
|
||||
resetTransform: () => {},
|
||||
restore: () => {},
|
||||
save: () => {},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textBaseline: 'alphabetic' as const,
|
||||
strokeRect: () => {},
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt' as const,
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
quadraticCurveTo: () => {},
|
||||
closePath: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {},
|
||||
};
|
||||
const snapshot: GhosttySnapshot = {
|
||||
cols: 3,
|
||||
rows: 1,
|
||||
foreground: { r: 255, g: 255, b: 255 },
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
cursor: { r: 255, g: 255, b: 255 },
|
||||
cursorX: 2,
|
||||
cursorY: 0,
|
||||
cursorVisible: true,
|
||||
cursorBlinking: true,
|
||||
cursorStyle: 1,
|
||||
dirtyRows: new Set(),
|
||||
rowData: [
|
||||
{
|
||||
cells: [cell('a'), cell('b'), cell('x')],
|
||||
text: 'abx',
|
||||
isWrapContinuation: false,
|
||||
wrapsToNext: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderGhosttySnapshot({
|
||||
context,
|
||||
snapshot,
|
||||
metrics: { width: 7.2, height: 16, baseline: 11 },
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
padding: 4,
|
||||
forceFull: false,
|
||||
cursorOn: false,
|
||||
});
|
||||
|
||||
// The cursor row still repaints so the block disappears, but the inverted
|
||||
// glyph the on phase draws over the cell is gone.
|
||||
expect(fillTextCalls).toEqual([['abx', 4, 15, 21.6]]);
|
||||
});
|
||||
|
||||
test('repaints the previous cursor row after the cursor moves', () => {
|
||||
const clearedRows: number[] = [];
|
||||
const context = {
|
||||
canvas: { width: 200, height: 80 },
|
||||
beginPath: () => {},
|
||||
clip: () => {},
|
||||
fillRect: (_left: number, top: number, _width: number, height: number) => {
|
||||
if (height === 16) clearedRows.push(top);
|
||||
},
|
||||
fillText: () => {},
|
||||
rect: () => {},
|
||||
resetTransform: () => {},
|
||||
restore: () => {},
|
||||
save: () => {},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textBaseline: 'alphabetic' as const,
|
||||
strokeRect: () => {},
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt' as const,
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
quadraticCurveTo: () => {},
|
||||
closePath: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {},
|
||||
};
|
||||
const snapshot: GhosttySnapshot = {
|
||||
cols: 1,
|
||||
rows: 3,
|
||||
foreground: { r: 255, g: 255, b: 255 },
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
cursor: { r: 255, g: 255, b: 255 },
|
||||
cursorX: 0,
|
||||
cursorY: 2,
|
||||
cursorVisible: true,
|
||||
cursorBlinking: false,
|
||||
cursorStyle: 1,
|
||||
dirtyRows: new Set(),
|
||||
rowData: [0, 1, 2].map(() => ({
|
||||
cells: [cell('')],
|
||||
text: '',
|
||||
isWrapContinuation: false,
|
||||
wrapsToNext: false,
|
||||
})),
|
||||
};
|
||||
|
||||
renderGhosttySnapshot({
|
||||
context,
|
||||
snapshot,
|
||||
metrics: { width: 7.2, height: 16, baseline: 11 },
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
padding: 4,
|
||||
forceFull: false,
|
||||
cursorOn: true,
|
||||
previousCursorY: 0,
|
||||
});
|
||||
|
||||
expect(clearedRows).toEqual([4, 36, 36]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
|
||||
import {
|
||||
GHOSTTY_CELL_WIDE,
|
||||
ghosttyColorsEqual,
|
||||
type GhosttyCell,
|
||||
type GhosttyColor,
|
||||
type GhosttySnapshot,
|
||||
} from './core';
|
||||
import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing';
|
||||
|
||||
/** The canvas operations the renderer uses; a CanvasRenderingContext2D satisfies it structurally. */
|
||||
export interface GhosttyRenderContext extends BoxDrawingContext {
|
||||
readonly canvas: { readonly width: number; readonly height: number };
|
||||
font: string;
|
||||
textBaseline: CanvasTextBaseline;
|
||||
fillRect(x: number, y: number, w: number, h: number): void;
|
||||
strokeRect(x: number, y: number, w: number, h: number): void;
|
||||
fillText(text: string, x: number, y: number, maxWidth?: number): void;
|
||||
save(): void;
|
||||
restore(): void;
|
||||
beginPath(): void;
|
||||
rect(x: number, y: number, w: number, h: number): void;
|
||||
clip(): void;
|
||||
resetTransform(): void;
|
||||
}
|
||||
|
||||
export interface GhosttyMeasureContext {
|
||||
font: string;
|
||||
measureText(text: string): {
|
||||
readonly width: number;
|
||||
readonly actualBoundingBoxAscent: number;
|
||||
readonly actualBoundingBoxDescent: number;
|
||||
};
|
||||
}
|
||||
|
||||
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: GhosttyMeasureContext,
|
||||
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 interface GhosttyGridSize {
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
}
|
||||
|
||||
export function terminalGridSize(
|
||||
width: number,
|
||||
height: number,
|
||||
metrics: GhosttyCellMetrics,
|
||||
padding: number,
|
||||
): GhosttyGridSize {
|
||||
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: GhosttyRenderContext;
|
||||
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;
|
||||
}
|
||||
// Borders, bars and block logos are drawn to the exact cell instead of
|
||||
// through the font, whose glyphs leave a gap at the terminal line height.
|
||||
if (isBoxDrawingText(first.text)) {
|
||||
if (!first.invisible) {
|
||||
drawBoxDrawingGlyph(
|
||||
context,
|
||||
first.text,
|
||||
{ x: padding + runStart * metrics.width, y: top, width: metrics.width, height: metrics.height },
|
||||
first.foreground,
|
||||
);
|
||||
}
|
||||
runStart += 1;
|
||||
continue;
|
||||
}
|
||||
const runEnd = ghosttyTextRunEnd(
|
||||
row.cells,
|
||||
runStart,
|
||||
(cell) => sameTextStyle(cell, first) && !isBoxDrawingText(cell.text),
|
||||
);
|
||||
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 && isBoxDrawingText(cell.text)) {
|
||||
drawBoxDrawingGlyph(
|
||||
context,
|
||||
cell.text,
|
||||
{ x: left, y: top, width: metrics.width, height: metrics.height },
|
||||
snapshot.background,
|
||||
);
|
||||
} else if (cell?.text) {
|
||||
context.font = fontForCell(cell, fontSize, fontFamily);
|
||||
context.fillStyle = cssColor(snapshot.background);
|
||||
context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadGhosttyRuntime } from './runtime';
|
||||
|
||||
const vendorDir = join(dirname(fileURLToPath(import.meta.url)), 'vendor');
|
||||
|
||||
describe('vendored libghostty-vt WebAssembly', () => {
|
||||
test('stays pinned to VERSION and inside the size budget', async () => {
|
||||
const wasm = readFileSync(join(vendorDir, 'ghostty-vt.wasm'));
|
||||
expect(wasm.byteLength).toBeLessThan(750_000);
|
||||
|
||||
// The build embeds the pinned revision as semver build metadata, so VERSION
|
||||
// is the single source of truth and drift between the two is caught here.
|
||||
const runtime = await loadGhosttyRuntime();
|
||||
const out = runtime.alloc(8);
|
||||
expect(runtime.call('ghostty_build_info', 10, out)).toBe(0);
|
||||
const view = runtime.view(out, 8);
|
||||
const embeddedRevision = new TextDecoder().decode(
|
||||
runtime.bytes(view.getUint32(0, true), view.getUint32(4, true)),
|
||||
);
|
||||
runtime.free(out, 8);
|
||||
expect(embeddedRevision).toBe(readFileSync(join(vendorDir, 'VERSION'), 'utf8').trim());
|
||||
});
|
||||
|
||||
test('routes terminal replies through the embedded trampoline to the attached writer', async () => {
|
||||
const runtime = await loadGhosttyRuntime();
|
||||
const optionsSize = runtime.layout('GhosttyTerminalOptions').size;
|
||||
const options = runtime.alloc(optionsSize);
|
||||
runtime.setField(options, 'GhosttyTerminalOptions', 'cols', 20);
|
||||
runtime.setField(options, 'GhosttyTerminalOptions', 'rows', 4);
|
||||
const slot = runtime.allocOpaque();
|
||||
expect(runtime.call('ghostty_terminal_new', 0, slot, options)).toBe(0);
|
||||
runtime.free(options, optionsSize);
|
||||
const terminal = runtime.readPointer(slot);
|
||||
|
||||
const replies: string[] = [];
|
||||
const writerId = runtime.attachPtyWriter(terminal, (data) => replies.push(data));
|
||||
const input = new TextEncoder().encode('\x1b[6n');
|
||||
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);
|
||||
expect(replies).toEqual(['\x1b[1;1R']);
|
||||
|
||||
runtime.detachPtyWriter(terminal, writerId);
|
||||
runtime.call('ghostty_terminal_free', terminal);
|
||||
runtime.freeOpaque(slot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
|
||||
type WasmFunction = (...args: Array<number | bigint>) => number;
|
||||
|
||||
interface TypeField {
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
interface TypeLayout {
|
||||
readonly size: number;
|
||||
readonly align: number;
|
||||
readonly fields: Readonly<Record<string, TypeField>>;
|
||||
}
|
||||
|
||||
type TypeLayouts = Readonly<Record<string, TypeLayout>>;
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
// The vendored artifact is fetched at runtime; `new URL` keeps this a plain
|
||||
// static asset for Vite in every surface (web, VS Code webview, Electron's
|
||||
// openchamber-ui:// protocol) and a readable file URL under bun's test runner.
|
||||
const ghosttyWasmUrl = new URL('./vendor/ghostty-vt.wasm', import.meta.url);
|
||||
|
||||
/**
|
||||
* Compiled from scripts/ghostty-write-pty.zig: one exported function that
|
||||
* forwards libghostty-vt's write-PTY callback to the `openchamber_write_pty`
|
||||
* import. Embedding the 121 bytes avoids a second network fetch and any CSP
|
||||
* question about data: URLs.
|
||||
*/
|
||||
const WRITE_PTY_TRAMPOLINE = Uint8Array.from([
|
||||
0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 1, 96, 4, 127, 127, 127, 127, 0, 2, 29, 1, 3, 101, 110, 118,
|
||||
21, 111, 112, 101, 110, 99, 104, 97, 109, 98, 101, 114, 95, 119, 114, 105, 116, 101, 95, 112,
|
||||
116, 121, 0, 0, 3, 2, 1, 0, 5, 3, 1, 0, 16, 6, 9, 1, 127, 1, 65, 128, 128, 192, 0, 11, 7, 30, 2,
|
||||
6, 109, 101, 109, 111, 114, 121, 2, 0, 17, 103, 104, 111, 115, 116, 116, 121, 95, 119, 114,
|
||||
105, 116, 101, 95, 112, 116, 121, 0, 1, 10, 18, 1, 16, 0, 32, 0, 32, 1, 32, 2, 32, 3, 16, 128,
|
||||
128, 128, 128, 0, 11,
|
||||
]);
|
||||
|
||||
export class GhosttyRuntime {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly layouts: TypeLayouts;
|
||||
private readonly exports: WebAssembly.Exports;
|
||||
private memoryView: DataView;
|
||||
private readonly ptyWriters = new Map<number, (data: string) => 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;
|
||||
this.memoryView = new DataView(memory.buffer);
|
||||
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;
|
||||
// SAFETY: ghostty_type_json is generated by the pinned libghostty-vt build
|
||||
// from its own C ABI structs; runtime.test.ts verifies the artifact matches
|
||||
// VERSION, so the document has exactly this shape.
|
||||
this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts;
|
||||
}
|
||||
|
||||
static async load(bytes?: ArrayBuffer): Promise<GhosttyRuntime> {
|
||||
const wasmBytes = bytes ?? (await fetchGhosttyWasm());
|
||||
// The log import needs the instance's memory, which only exists once
|
||||
// instantiation returns; the holder closes that loop.
|
||||
const instanceHolder: { current: WebAssembly.Instance | null } = { current: null };
|
||||
const imports = {
|
||||
env: {
|
||||
log: (pointer: number, length: number) => {
|
||||
const memory = instanceHolder.current?.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);
|
||||
instanceHolder.current = result.instance;
|
||||
const runtime = new GhosttyRuntime(result.instance);
|
||||
await runtime.installWritePtyTrampoline();
|
||||
return runtime;
|
||||
}
|
||||
|
||||
call(name: string, ...args: Array<number | bigint>): number {
|
||||
const fn = this.exports[name];
|
||||
if (!(fn instanceof Function)) {
|
||||
throw new Error(`libghostty-vt export is unavailable: ${name}`);
|
||||
}
|
||||
// SAFETY: every libghostty-vt export takes and returns wasm32 scalars (i32/i64).
|
||||
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 this.currentMemoryView().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);
|
||||
}
|
||||
|
||||
/** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */
|
||||
private currentMemoryView(): DataView {
|
||||
if (this.memoryView.buffer !== this.memory.buffer) {
|
||||
this.memoryView = new DataView(this.memory.buffer);
|
||||
}
|
||||
return this.memoryView;
|
||||
}
|
||||
|
||||
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.currentMemoryView();
|
||||
const offset = pointer + field.offset;
|
||||
switch (field.type) {
|
||||
case 'bool':
|
||||
case 'u8':
|
||||
view.setUint8(offset, value);
|
||||
return;
|
||||
case 'u16':
|
||||
view.setUint16(offset, value, true);
|
||||
return;
|
||||
case 'i32':
|
||||
view.setInt32(offset, value, true);
|
||||
return;
|
||||
case 'u32':
|
||||
case 'enum':
|
||||
view.setUint32(offset, value, true);
|
||||
return;
|
||||
case 'u64':
|
||||
view.setBigUint64(offset, 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.currentMemoryView();
|
||||
const offset = pointer + field.offset;
|
||||
switch (field.type) {
|
||||
case 'bool':
|
||||
case 'u8':
|
||||
return view.getUint8(offset);
|
||||
case 'u16':
|
||||
return view.getUint16(offset, true);
|
||||
case 'i32':
|
||||
return view.getInt32(offset, true);
|
||||
case 'u32':
|
||||
case 'enum':
|
||||
return view.getUint32(offset, true);
|
||||
case 'u64':
|
||||
return Number(view.getBigUint64(offset, true));
|
||||
default:
|
||||
throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async installWritePtyTrampoline(): Promise<void> {
|
||||
const result = await WebAssembly.instantiate(WRITE_PTY_TRAMPOLINE, {
|
||||
env: {
|
||||
openchamber_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 (!(trampoline instanceof 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGhosttyWasm(): Promise<ArrayBuffer> {
|
||||
const response = await fetch(ghosttyWasmUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load libghostty-vt (${response.status})`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
let runtimePromise: Promise<GhosttyRuntime> | null = null;
|
||||
|
||||
/** One WebAssembly instance per page; every terminal owns and frees its own handles inside it. */
|
||||
export function loadGhosttyRuntime(): Promise<GhosttyRuntime> {
|
||||
runtimePromise ??= GhosttyRuntime.load().catch((error) => {
|
||||
runtimePromise = null;
|
||||
throw error;
|
||||
});
|
||||
return runtimePromise;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { GhosttyCell, GhosttyRow } from './core';
|
||||
import {
|
||||
DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
advanceTerminalSelectionClickSequence,
|
||||
isTerminalCopyShortcut,
|
||||
isTerminalLinkPointerGesture,
|
||||
isTerminalPasteShortcut,
|
||||
resolveTerminalMouseData,
|
||||
shouldBlinkTerminalCursor,
|
||||
terminalContentOriginY,
|
||||
terminalFontSize,
|
||||
terminalGridCellAt,
|
||||
terminalLinkAtPositionWithRange,
|
||||
terminalScrollbarGeometry,
|
||||
terminalScrollbarOffsetAtPointer,
|
||||
terminalWheelArrowData,
|
||||
terminalWheelDeltaRows,
|
||||
loadTerminalFontFamily,
|
||||
} from './surface';
|
||||
|
||||
const cell = (text: string): GhosttyCell => ({
|
||||
text,
|
||||
wide: 0,
|
||||
foreground: { r: 255, g: 255, b: 255 },
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
bold: false,
|
||||
italic: false,
|
||||
invisible: false,
|
||||
strikethrough: false,
|
||||
overline: false,
|
||||
underline: false,
|
||||
selected: false,
|
||||
});
|
||||
|
||||
const row = (text: string, cols: number, flags: Partial<Pick<GhosttyRow, 'isWrapContinuation' | 'wrapsToNext'>> = {}): GhosttyRow => ({
|
||||
cells: Array.from({ length: cols }, (_, index) => cell([...text][index] ?? '')),
|
||||
text: text.trimEnd(),
|
||||
isWrapContinuation: flags.isWrapContinuation ?? false,
|
||||
wrapsToNext: flags.wrapsToNext ?? false,
|
||||
});
|
||||
|
||||
describe('terminalLinkAtPositionWithRange', () => {
|
||||
test('reconstructs a URL soft-wrapped across two rows', () => {
|
||||
const rows = [
|
||||
row('see https://open', 16, { wrapsToNext: true }),
|
||||
row('chamber.dev/docs', 16, { isWrapContinuation: true }),
|
||||
row('done', 16),
|
||||
];
|
||||
const link = terminalLinkAtPositionWithRange(rows, 1, 3);
|
||||
expect(link).toEqual({
|
||||
text: 'https://openchamber.dev/docs',
|
||||
range: { start: { x: 4, y: 0 }, end: { x: 15, y: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses a link whose head scrolled above the viewport', () => {
|
||||
const rows = [row('chamber.dev/docs', 16, { isWrapContinuation: true }), row('', 16)];
|
||||
expect(terminalLinkAtPositionWithRange(rows, 0, 2)).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores plain text', () => {
|
||||
expect(terminalLinkAtPositionWithRange([row('hello world', 16)], 0, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal font resolution', () => {
|
||||
test('keeps the glyph fallbacks behind a custom text face and drops canvas-hostile generics', async () => {
|
||||
const loads: string[] = [];
|
||||
const family = await loadTerminalFontFamily('ui-monospace, "JetBrains Mono", monospace', 13, {
|
||||
load: (font) => {
|
||||
loads.push(font);
|
||||
return Promise.resolve();
|
||||
},
|
||||
resolve: (value) => `resolved:${value}`,
|
||||
});
|
||||
expect(family).toBe('resolved:ui-monospace, "JetBrains Mono", monospace');
|
||||
expect(loads).toHaveLength(4);
|
||||
expect(loads[0]?.startsWith('normal 400 13px "JetBrains Mono", monospace, "SF Mono"')).toBe(true);
|
||||
expect(loads[0]).not.toContain('ui-monospace');
|
||||
});
|
||||
|
||||
test('clamps requested font sizes to the supported range', () => {
|
||||
expect(terminalFontSize(undefined)).toBe(13);
|
||||
expect(terminalFontSize(2)).toBe(6);
|
||||
expect(terminalFontSize(99)).toBe(32);
|
||||
expect(terminalFontSize(14.4)).toBe(14);
|
||||
});
|
||||
|
||||
test('the default stack names only concrete faces plus the bundled symbols', () => {
|
||||
expect(DEFAULT_TERMINAL_FONT_FAMILY).toContain('"Symbols Nerd Font Mono"');
|
||||
expect(DEFAULT_TERMINAL_FONT_FAMILY).not.toContain('ui-monospace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts and gestures', () => {
|
||||
test('copy uses Cmd on macOS and Ctrl elsewhere, keeping Ctrl+C for SIGINT on macOS', () => {
|
||||
expect(isTerminalCopyShortcut({ key: 'c', ctrlKey: true, metaKey: false, shiftKey: false }, 'MacIntel')).toBe(false);
|
||||
expect(isTerminalCopyShortcut({ key: 'c', ctrlKey: false, metaKey: true, shiftKey: false }, 'MacIntel')).toBe(true);
|
||||
expect(isTerminalCopyShortcut({ key: 'C', ctrlKey: true, metaKey: false, shiftKey: true }, 'Linux x86_64')).toBe(true);
|
||||
});
|
||||
|
||||
test('paste uses Cmd+V on macOS, Ctrl+Shift+V or Shift+Insert elsewhere', () => {
|
||||
expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: false, metaKey: true, shiftKey: false }, 'MacIntel')).toBe(true);
|
||||
expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: true, metaKey: false, shiftKey: false }, 'Win32')).toBe(false);
|
||||
expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: true, metaKey: false, shiftKey: true }, 'Win32')).toBe(true);
|
||||
expect(isTerminalPasteShortcut({ key: 'Insert', ctrlKey: false, metaKey: false, shiftKey: true }, 'Win32')).toBe(true);
|
||||
});
|
||||
|
||||
test('link activation uses Command on macOS and Control elsewhere', () => {
|
||||
expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, 'MacIntel')).toBe(true);
|
||||
expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, 'MacIntel')).toBe(false);
|
||||
expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, 'Linux x86_64')).toBe(true);
|
||||
});
|
||||
|
||||
test('recognizes stationary double and triple presses and restarts after movement', () => {
|
||||
const first = advanceTerminalSelectionClickSequence(null, { clientX: 10, clientY: 10, timeStamp: 0 });
|
||||
const second = advanceTerminalSelectionClickSequence(first, { clientX: 11, clientY: 10, timeStamp: 200 });
|
||||
const third = advanceTerminalSelectionClickSequence(second, { clientX: 11, clientY: 11, timeStamp: 400 });
|
||||
expect([first.count, second.count, third.count]).toEqual([1, 2, 3]);
|
||||
expect(advanceTerminalSelectionClickSequence(third, { clientX: 11, clientY: 11, timeStamp: 600 }).count).toBe(1);
|
||||
expect(advanceTerminalSelectionClickSequence(second, { clientX: 40, clientY: 10, timeStamp: 500 }).count).toBe(1);
|
||||
});
|
||||
|
||||
test('drops repeated motion reports until another action resets the cell', () => {
|
||||
const motion = resolveTerminalMouseData('motion', '\x1b[<35;3;4M', '');
|
||||
expect(motion.send).toBe(true);
|
||||
expect(resolveTerminalMouseData('motion', '\x1b[<35;3;4M', motion.nextMotionData).send).toBe(false);
|
||||
const press = resolveTerminalMouseData('press', '\x1b[<0;3;4M', motion.nextMotionData);
|
||||
expect(press).toEqual({ send: true, nextMotionData: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wheel scrolling', () => {
|
||||
test('converts line and page deltas into rows and accumulates fractional pixels', () => {
|
||||
expect(terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0)).toEqual({ rows: 3, remainder: 0 });
|
||||
expect(terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0)).toEqual({ rows: -24, remainder: 0 });
|
||||
const partial = terminalWheelDeltaRows({ deltaY: 10, deltaMode: 0 }, 16, 24, 0);
|
||||
expect(partial.rows).toBe(0);
|
||||
expect(terminalWheelDeltaRows({ deltaY: 10, deltaMode: 0 }, 16, 24, partial.remainder).rows).toBe(1);
|
||||
});
|
||||
|
||||
test('emits one arrow per row honoring application cursor keys', () => {
|
||||
expect(terminalWheelArrowData(-2, false)).toBe('\x1b[A\x1b[A');
|
||||
expect(terminalWheelArrowData(1, true)).toBe('\x1bOB');
|
||||
expect(terminalWheelArrowData(0, false)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout helpers', () => {
|
||||
test('anchors the grid to the bottom only once scrollback exists', () => {
|
||||
expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4);
|
||||
expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16);
|
||||
});
|
||||
|
||||
test('maps points inside the rendered grid without clamping its padding', () => {
|
||||
const options = { bounds: { left: 10, top: 20 }, cols: 10, rows: 5, metrics: { width: 8, height: 16 }, padding: 4, originY: 4 };
|
||||
expect(terminalGridCellAt({ ...options, clientX: 14, clientY: 24 })).toEqual({ x: 0, y: 0 });
|
||||
expect(terminalGridCellAt({ ...options, clientX: 93, clientY: 103 })).toEqual({ x: 9, y: 4 });
|
||||
expect(terminalGridCellAt({ ...options, clientX: 12, clientY: 24 })).toBeNull();
|
||||
});
|
||||
|
||||
test('maps Ghostty scrollbar state to a proportional thumb and back to rows', () => {
|
||||
const state = { total: 1000, offset: 500, len: 100 };
|
||||
const geometry = terminalScrollbarGeometry(state, 200);
|
||||
expect(geometry).toEqual({ thumbHeight: 20, thumbTop: 100, maxOffset: 900 });
|
||||
expect(terminalScrollbarOffsetAtPointer(state, 200, 190, 10)).toBe(900);
|
||||
expect(terminalScrollbarGeometry({ total: 24, offset: 0, len: 24 }, 200)).toBeNull();
|
||||
});
|
||||
|
||||
test('blinks only a focused visible cursor the terminal asked to blink', () => {
|
||||
expect(shouldBlinkTerminalCursor({ focused: true, cursorBlinking: true, cursorVisible: true, reducedMotion: false })).toBe(true);
|
||||
expect(shouldBlinkTerminalCursor({ focused: false, cursorBlinking: true, cursorVisible: true, reducedMotion: false })).toBe(false);
|
||||
expect(shouldBlinkTerminalCursor({ focused: true, cursorBlinking: true, cursorVisible: true, reducedMotion: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { collectWrappedTerminalLinkLine, extractTerminalLinks } from './terminalLinks';
|
||||
|
||||
describe('extractTerminalLinks', () => {
|
||||
test('finds http(s) URLs and trims trailing punctuation and unbalanced brackets', () => {
|
||||
expect(extractTerminalLinks('see https://example.com/a?b=1). and http://x.y/z,')).toEqual([
|
||||
{ text: 'https://example.com/a?b=1', start: 4, end: 29 },
|
||||
{ text: 'http://x.y/z', start: 36, end: 48 },
|
||||
]);
|
||||
expect(extractTerminalLinks('(https://example.com/(a))')).toEqual([
|
||||
{ text: 'https://example.com/(a)', start: 1, end: 24 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores bare paths and other schemes', () => {
|
||||
expect(extractTerminalLinks('src/lib/x.ts:12 ftp://host/file')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectWrappedTerminalLinkLine', () => {
|
||||
test('joins wrapped rows and records each segment offset', () => {
|
||||
const lines = [
|
||||
{ isWrapped: false, translateToString: () => 'abc' },
|
||||
{ isWrapped: true, translateToString: () => 'def' },
|
||||
{ isWrapped: false, translateToString: () => 'ghi' },
|
||||
];
|
||||
expect(collectWrappedTerminalLinkLine(2, (index) => lines[index])).toEqual({
|
||||
text: 'abcdef',
|
||||
segments: [
|
||||
{ bufferLineNumber: 1, text: 'abc', startIndex: 0, endIndex: 3 },
|
||||
{ bufferLineNumber: 2, text: 'def', startIndex: 3, endIndex: 6 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null when the wrapped head is unavailable', () => {
|
||||
const lines = [undefined, { isWrapped: true, translateToString: () => 'x' }];
|
||||
expect(collectWrappedTerminalLinkLine(2, (index) => lines[index])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
|
||||
export interface TerminalLinkMatch {
|
||||
readonly text: string;
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
export interface TerminalBufferLineLike {
|
||||
readonly isWrapped?: boolean;
|
||||
translateToString(trimRight?: boolean): string;
|
||||
}
|
||||
|
||||
export interface WrappedTerminalLinkLineSegment {
|
||||
readonly bufferLineNumber: number;
|
||||
readonly text: string;
|
||||
readonly startIndex: number;
|
||||
readonly endIndex: number;
|
||||
}
|
||||
|
||||
export interface WrappedTerminalLinkLine {
|
||||
readonly text: string;
|
||||
readonly segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;
|
||||
}
|
||||
|
||||
const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;
|
||||
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;
|
||||
}
|
||||
|
||||
/** http(s) URLs in one logical line, with trailing punctuation and unbalanced brackets trimmed. */
|
||||
export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
|
||||
const matches: TerminalLinkMatch[] = [];
|
||||
URL_PATTERN.lastIndex = 0;
|
||||
for (const rawMatch of line.matchAll(URL_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;
|
||||
matches.push({ text: trimmed, start, end: start + trimmed.length });
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a soft-wrapped line back together so a URL that the terminal broke
|
||||
* across rows matches as one string, remembering where each row's text sits.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
+21
@@ -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.
|
||||
+1
@@ -0,0 +1 @@
|
||||
9f62873bf195e4d8a762d768a1405a5f2f7b1697
|
||||
Binary file not shown.
@@ -1646,6 +1646,7 @@ export const dict = {
|
||||
'terminalView.tabs.closeTabTitle': 'Registerkarte schließen',
|
||||
'terminalView.tabs.newTabTitle': 'Neue Registerkarte',
|
||||
'terminalView.viewport.inputAria': 'Terminal-Eingabe',
|
||||
'terminalView.viewport.scrollbarAria': 'Terminal-Verlauf',
|
||||
'directoryExplorerDialog.title': 'Projektverzeichnis hinzufügen',
|
||||
'directoryExplorerDialog.description': 'Wählen Sie einen Ordner aus, der als Projekt hinzugefügt werden soll.',
|
||||
'directoryExplorerDialog.toggle.showHidden': 'Versteckte anzeigen',
|
||||
@@ -2979,6 +2980,9 @@ export const dict = {
|
||||
'quota.window.completions': 'Vervollständigungen',
|
||||
'quota.window.premiumInteractions': 'KI-Guthaben',
|
||||
'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen',
|
||||
'terminalView.actions.copySelection': 'Ausgewählte Ausgabe kopieren',
|
||||
'terminalView.toast.selectionCopied': 'Ausgabe kopiert',
|
||||
'terminalView.toast.copyFailed': 'Kopieren fehlgeschlagen',
|
||||
'terminalView.actions.restart': 'Terminal neu starten',
|
||||
'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Kommentar zu {file}, Zeilen {start}-{end}',
|
||||
|
||||
@@ -8,6 +8,9 @@ export const dict = {
|
||||
...linearIssuePickerI18n.en,
|
||||
...linearPanelI18n.en,
|
||||
'terminalView.actions.attachSelection': 'Attach selected output',
|
||||
'terminalView.actions.copySelection': 'Copy selected output',
|
||||
'terminalView.toast.selectionCopied': 'Output copied',
|
||||
'terminalView.toast.copyFailed': 'Copy failed',
|
||||
'terminalView.actions.restart': 'Restart terminal',
|
||||
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Comment on {file}, lines {start}-{end}',
|
||||
@@ -1847,6 +1850,7 @@ export const dict = {
|
||||
'terminalView.tabs.closeTabTitle': 'Close tab',
|
||||
'terminalView.tabs.newTabTitle': 'New tab',
|
||||
'terminalView.viewport.inputAria': 'Terminal input',
|
||||
'terminalView.viewport.scrollbarAria': 'Terminal scrollback',
|
||||
'directoryExplorerDialog.title': 'Add project directory',
|
||||
'directoryExplorerDialog.description': 'Choose a folder to add as a project.',
|
||||
'directoryExplorerDialog.toggle.showHidden': 'Show hidden',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n.es,
|
||||
...linearPanelI18n.es,
|
||||
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
|
||||
'terminalView.actions.copySelection': 'Copiar salida seleccionada',
|
||||
'terminalView.toast.selectionCopied': 'Salida copiada',
|
||||
'terminalView.toast.copyFailed': 'Error al copiar',
|
||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Comentario en {file}, líneas {start}-{end}',
|
||||
@@ -1825,6 +1828,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"terminalView.tabs.closeTabTitle": "Cerrar pestaña",
|
||||
"terminalView.tabs.newTabTitle": "Nueva pestaña",
|
||||
"terminalView.viewport.inputAria": "Entrada de terminal",
|
||||
"terminalView.viewport.scrollbarAria": "Historial del terminal",
|
||||
"directoryExplorerDialog.title": "Añadir directorio de proyecto",
|
||||
"directoryExplorerDialog.description": "Elige una carpeta para añadir como proyecto.",
|
||||
"directoryExplorerDialog.toggle.showHidden": "Mostrar ocultos",
|
||||
|
||||
@@ -8,6 +8,9 @@ export const dict = {
|
||||
...linearIssuePickerI18n.fr,
|
||||
...linearPanelI18n.fr,
|
||||
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
|
||||
'terminalView.actions.copySelection': 'Copier la sortie sélectionnée',
|
||||
'terminalView.toast.selectionCopied': 'Sortie copiée',
|
||||
'terminalView.toast.copyFailed': 'Échec de la copie',
|
||||
'terminalView.actions.restart': 'Redémarrer le terminal',
|
||||
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Commentaire sur {file}, lignes {start}-{end}',
|
||||
@@ -1604,6 +1607,7 @@ export const dict = {
|
||||
'terminalView.tabs.closeTabTitle': 'Fermer l\'onglet',
|
||||
'terminalView.tabs.newTabTitle': 'Nouvel onglet',
|
||||
'terminalView.viewport.inputAria': 'Entrée de borne',
|
||||
'terminalView.viewport.scrollbarAria': 'Historique du terminal',
|
||||
'directoryExplorerDialog.title': 'Ajouter un répertoire de projet',
|
||||
'directoryExplorerDialog.description': 'Choisissez un dossier à ajouter en tant que projet.',
|
||||
'directoryExplorerDialog.toggle.showHidden': 'Afficher masqué',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n.ja,
|
||||
...linearPanelI18n.ja,
|
||||
'terminalView.actions.attachSelection': '選択した出力を添付',
|
||||
'terminalView.actions.copySelection': '選択した出力をコピー',
|
||||
'terminalView.toast.selectionCopied': '出力をコピーしました',
|
||||
'terminalView.toast.copyFailed': 'コピーに失敗しました',
|
||||
'terminalView.actions.restart': 'ターミナルを再起動',
|
||||
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
|
||||
'chat.message.context.codeComment': '{file} の {start}〜{end} 行へのコメント',
|
||||
@@ -1843,6 +1846,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.tabs.closeTabTitle': 'タブを閉じる',
|
||||
'terminalView.tabs.newTabTitle': '新しいタブ',
|
||||
'terminalView.viewport.inputAria': 'ターミナル入力',
|
||||
'terminalView.viewport.scrollbarAria': 'ターミナルのスクロールバック',
|
||||
'directoryExplorerDialog.title': 'プロジェクトディレクトリを追加',
|
||||
'directoryExplorerDialog.description': 'プロジェクトとして追加するフォルダを選択してください。',
|
||||
'directoryExplorerDialog.toggle.showHidden': '隠しファイルを表示',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n.ko,
|
||||
...linearPanelI18n.ko,
|
||||
'terminalView.actions.attachSelection': '선택한 출력 첨부',
|
||||
'terminalView.actions.copySelection': '선택한 출력 복사',
|
||||
'terminalView.toast.selectionCopied': '출력을 복사했습니다',
|
||||
'terminalView.toast.copyFailed': '복사 실패',
|
||||
'terminalView.actions.restart': '터미널 다시 시작',
|
||||
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
|
||||
'chat.message.context.codeComment': '{file} {start}-{end}행에 대한 댓글',
|
||||
@@ -1849,6 +1852,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.tabs.closeTabTitle': '탭 닫기',
|
||||
'terminalView.tabs.newTabTitle': '새 탭',
|
||||
'terminalView.viewport.inputAria': '터미널 입력',
|
||||
'terminalView.viewport.scrollbarAria': '터미널 스크롤백',
|
||||
'directoryExplorerDialog.title': '프로젝트 디렉터리 추가',
|
||||
'directoryExplorerDialog.description': '프로젝트로 추가할 폴더를 선택하세요.',
|
||||
'directoryExplorerDialog.toggle.showHidden': '숨김 항목 표시',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n.pl,
|
||||
...linearPanelI18n.pl,
|
||||
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
|
||||
'terminalView.actions.copySelection': 'Kopiuj zaznaczone dane wyjściowe',
|
||||
'terminalView.toast.selectionCopied': 'Skopiowano dane wyjściowe',
|
||||
'terminalView.toast.copyFailed': 'Kopiowanie nie powiodło się',
|
||||
'terminalView.actions.restart': 'Uruchom terminal ponownie',
|
||||
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Komentarz do {file}, wiersze {start}-{end}',
|
||||
@@ -3043,6 +3046,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.tabs.closeTabTitle': 'Close tab',
|
||||
'terminalView.tabs.newTabTitle': 'New tab',
|
||||
'terminalView.viewport.inputAria': 'Terminal input',
|
||||
'terminalView.viewport.scrollbarAria': 'Historia terminala',
|
||||
'textarea.resizeHandleAria': 'Resize textarea',
|
||||
'updateDialog.actions.copied': 'Skopiowano!',
|
||||
'updateDialog.actions.copyCommand': 'Kopiuj polecenie',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n['pt-BR'],
|
||||
...linearPanelI18n['pt-BR'],
|
||||
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
|
||||
'terminalView.actions.copySelection': 'Copiar saída selecionada',
|
||||
'terminalView.toast.selectionCopied': 'Saída copiada',
|
||||
'terminalView.toast.copyFailed': 'Falha ao copiar',
|
||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Comentário em {file}, linhas {start}-{end}',
|
||||
@@ -1825,6 +1828,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"terminalView.tabs.closeTabTitle": "Fechar aba",
|
||||
"terminalView.tabs.newTabTitle": "Nova aba",
|
||||
"terminalView.viewport.inputAria": "Entrada de terminal",
|
||||
"terminalView.viewport.scrollbarAria": "Histórico do terminal",
|
||||
"directoryExplorerDialog.title": "Adicionar diretório de projeto",
|
||||
"directoryExplorerDialog.description": "Escolha uma pasta para adicionar como projeto.",
|
||||
"directoryExplorerDialog.toggle.showHidden": "Mostrar ocultos",
|
||||
|
||||
@@ -8,6 +8,9 @@ export const dict = {
|
||||
...linearIssuePickerI18n.tr,
|
||||
...linearPanelI18n.tr,
|
||||
'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle',
|
||||
'terminalView.actions.copySelection': 'Seçili çıktıyı kopyala',
|
||||
'terminalView.toast.selectionCopied': 'Çıktı kopyalandı',
|
||||
'terminalView.toast.copyFailed': 'Kopyalama başarısız',
|
||||
'terminalView.actions.restart': 'Terminali yeniden başlat',
|
||||
'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar',
|
||||
'chat.chatInput.terminalContext': '{terminal}, {start}-{end}. satırlar',
|
||||
@@ -1809,6 +1812,7 @@ export const dict = {
|
||||
'terminalView.tabs.closeTabTitle': 'Sekmeyi kapat',
|
||||
'terminalView.tabs.newTabTitle': 'Yeni sekme',
|
||||
'terminalView.viewport.inputAria': 'Terminal girişi',
|
||||
'terminalView.viewport.scrollbarAria': 'Terminal geçmişi',
|
||||
'directoryExplorerDialog.title': 'Proje dizini ekle',
|
||||
'directoryExplorerDialog.description': 'Proje olarak eklemek için bir klasör seçin.',
|
||||
'directoryExplorerDialog.toggle.showHidden': 'Gizli dosyaları göster',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n.uk,
|
||||
...linearPanelI18n.uk,
|
||||
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
|
||||
'terminalView.actions.copySelection': 'Скопіювати вибраний вивід',
|
||||
'terminalView.toast.selectionCopied': 'Вивід скопійовано',
|
||||
'terminalView.toast.copyFailed': 'Не вдалося скопіювати',
|
||||
'terminalView.actions.restart': 'Перезапустити термінал',
|
||||
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Коментар до {file}, рядки {start}-{end}',
|
||||
@@ -1825,6 +1828,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"terminalView.tabs.closeTabTitle": "Закрити вкладку",
|
||||
"terminalView.tabs.newTabTitle": "Нова вкладка",
|
||||
"terminalView.viewport.inputAria": "Ввід терміналу",
|
||||
"terminalView.viewport.scrollbarAria": "Історія термінала",
|
||||
"directoryExplorerDialog.title": "Додати каталог проєкту",
|
||||
"directoryExplorerDialog.description": "Виберіть папку, щоб додати її як проєкт.",
|
||||
"directoryExplorerDialog.toggle.showHidden": "Показати приховані",
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n['zh-CN'],
|
||||
...linearPanelI18n['zh-CN'],
|
||||
'terminalView.actions.attachSelection': '附加所选输出',
|
||||
'terminalView.actions.copySelection': '复制所选输出',
|
||||
'terminalView.toast.selectionCopied': '已复制输出',
|
||||
'terminalView.toast.copyFailed': '复制失败',
|
||||
'terminalView.actions.restart': '重启终端',
|
||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
'chat.message.context.codeComment': '对 {file} 第 {start}-{end} 行的评论',
|
||||
@@ -1813,6 +1816,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.tabs.closeTabTitle': '关闭标签页',
|
||||
'terminalView.tabs.newTabTitle': '新建标签页',
|
||||
'terminalView.viewport.inputAria': '终端输入',
|
||||
'terminalView.viewport.scrollbarAria': '终端回滚历史',
|
||||
'directoryExplorerDialog.title': '添加项目目录',
|
||||
'directoryExplorerDialog.description': '选择一个文件夹添加为项目。',
|
||||
'directoryExplorerDialog.toggle.showHidden': '显示隐藏项',
|
||||
|
||||
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
...linearIssuePickerI18n['zh-TW'],
|
||||
...linearPanelI18n['zh-TW'],
|
||||
'terminalView.actions.attachSelection': '附加所選輸出',
|
||||
'terminalView.actions.copySelection': '複製所選輸出',
|
||||
'terminalView.toast.selectionCopied': '已複製輸出',
|
||||
'terminalView.toast.copyFailed': '複製失敗',
|
||||
'terminalView.actions.restart': '重新啟動終端',
|
||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
'chat.message.context.codeComment': '對 {file} 第 {start}-{end} 行的評論',
|
||||
@@ -1817,6 +1820,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.tabs.closeTabTitle': '關閉分頁',
|
||||
'terminalView.tabs.newTabTitle': '新增分頁',
|
||||
'terminalView.viewport.inputAria': '終端機輸入',
|
||||
'terminalView.viewport.scrollbarAria': '終端機回捲歷史',
|
||||
'directoryExplorerDialog.title': '新增專案目錄',
|
||||
'directoryExplorerDialog.description': '選擇一個資料夾新增為專案。',
|
||||
'directoryExplorerDialog.toggle.showHidden': '顯示隱藏項目',
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getGhosttySafeResetSequence,
|
||||
rewriteGhosttyDefaultBackgroundResets,
|
||||
} from './terminalOutput';
|
||||
|
||||
describe('terminal output compatibility', () => {
|
||||
test('builds an explicit default-background reset from supported CSS colors', () => {
|
||||
expect(getGhosttySafeResetSequence('#f8f7f0')).toBe('\u001b[0;48;2;248;247;240m');
|
||||
expect(getGhosttySafeResetSequence('#abc')).toBe('\u001b[0;48;2;170;187;204m');
|
||||
expect(getGhosttySafeResetSequence('rgb(12, 34, 56)')).toBe('\u001b[0;48;2;12;34;56m');
|
||||
expect(getGhosttySafeResetSequence('var(--surface-background)')).toBeNull();
|
||||
});
|
||||
|
||||
test('rewrites default resets even when escape sequences span chunks', () => {
|
||||
const safeReset = '\u001b[0;48;2;10;20;30m';
|
||||
const first = rewriteGhosttyDefaultBackgroundResets('before\u001b[', '', safeReset);
|
||||
const second = rewriteGhosttyDefaultBackgroundResets('0mafter\u001b[m', first.carry, safeReset);
|
||||
|
||||
expect(first).toEqual({ data: 'before', carry: '\u001b[' });
|
||||
expect(second).toEqual({ data: `${safeReset}after${safeReset}`, carry: '' });
|
||||
});
|
||||
|
||||
test('preserves output when the background cannot be resolved', () => {
|
||||
expect(rewriteGhosttyDefaultBackgroundResets('0m', '\u001b[', null)).toEqual({
|
||||
data: '\u001b[0m',
|
||||
carry: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// ghostty-web 0.4.0 leaves recycled rows dirty after default SGR resets (#138).
|
||||
// Keep the theme background explicit until a stable release includes the upstream WASM fix.
|
||||
const DEFAULT_BACKGROUND_RESETS = ['\u001b[0m', '\u001b[m'] as const;
|
||||
|
||||
const parseCssRgb = (color: string): [number, number, number] | null => {
|
||||
const value = color.trim();
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1];
|
||||
if (hex) {
|
||||
const expanded = hex.length <= 4
|
||||
? hex.slice(0, 3).split('').map((part) => part + part).join('')
|
||||
: hex.slice(0, 6);
|
||||
return [
|
||||
Number.parseInt(expanded.slice(0, 2), 16),
|
||||
Number.parseInt(expanded.slice(2, 4), 16),
|
||||
Number.parseInt(expanded.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value);
|
||||
if (!rgb) return null;
|
||||
const channels = rgb.slice(1, 4).map(Number);
|
||||
if (channels.some((channel) => channel < 0 || channel > 255)) return null;
|
||||
return channels as [number, number, number];
|
||||
};
|
||||
|
||||
export const getGhosttySafeResetSequence = (background: string): string | null => {
|
||||
const rgb = parseCssRgb(background);
|
||||
return rgb ? `\u001b[0;48;2;${rgb[0]};${rgb[1]};${rgb[2]}m` : null;
|
||||
};
|
||||
|
||||
export const rewriteGhosttyDefaultBackgroundResets = (
|
||||
data: string,
|
||||
carry: string,
|
||||
safeReset: string | null,
|
||||
): { data: string; carry: string } => {
|
||||
const combined = carry + data;
|
||||
if (!safeReset) return { data: combined, carry: '' };
|
||||
|
||||
let carryLength = 0;
|
||||
const maxPrefixLength = Math.max(...DEFAULT_BACKGROUND_RESETS.map((reset) => reset.length)) - 1;
|
||||
for (let length = 1; length <= Math.min(maxPrefixLength, combined.length); length += 1) {
|
||||
const suffix = combined.slice(-length);
|
||||
if (DEFAULT_BACKGROUND_RESETS.some((reset) => reset.length > suffix.length && reset.startsWith(suffix))) {
|
||||
carryLength = length;
|
||||
}
|
||||
}
|
||||
|
||||
const nextCarry = carryLength > 0 ? combined.slice(-carryLength) : '';
|
||||
let output = carryLength > 0 ? combined.slice(0, -carryLength) : combined;
|
||||
for (const reset of DEFAULT_BACKGROUND_RESETS) output = output.replaceAll(reset, safeReset);
|
||||
return { data: output, carry: nextCarry };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Ghostty } from 'ghostty-web';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import type { GhosttyColor, GhosttyTheme } from '@/lib/ghostty/core';
|
||||
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
@@ -62,53 +62,46 @@ export function convertThemeToXterm(theme: Theme): TerminalTheme {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal options for Ghostty Web terminal
|
||||
*/
|
||||
export function getGhosttyTerminalOptions(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
theme: TerminalTheme,
|
||||
ghostty: Ghostty,
|
||||
disableStdin = false
|
||||
) {
|
||||
const powerlineFallbacks =
|
||||
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
||||
const ANSI_ORDER = [
|
||||
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
|
||||
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
|
||||
] as const;
|
||||
|
||||
/** Parses #rgb, #rrggbb (alpha digits ignored) or rgb()/rgba() into channels. */
|
||||
const parseTerminalColor = (color: string): GhosttyColor | null => {
|
||||
const value = color.trim();
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1];
|
||||
if (hex) {
|
||||
const expanded = hex.length <= 4
|
||||
? hex.slice(0, 3).split('').map((part) => part + part).join('')
|
||||
: hex.slice(0, 6);
|
||||
return {
|
||||
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value);
|
||||
if (!rgb) return null;
|
||||
const [r, g, b] = rgb.slice(1, 4).map(Number);
|
||||
if ([r, g, b].some((channel) => channel === undefined || channel < 0 || channel > 255)) return null;
|
||||
return { r: r ?? 0, g: g ?? 0, b: b ?? 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme colors as libghostty-vt takes them. Theme JSON values are hex, so a
|
||||
* parse failure means a broken theme file: fall back to plain white on black
|
||||
* for that entry rather than sending Ghostty garbage.
|
||||
*/
|
||||
export function toGhosttyTheme(theme: TerminalTheme): GhosttyTheme {
|
||||
const background = parseTerminalColor(theme.background) ?? { r: 0, g: 0, b: 0 };
|
||||
const foreground = parseTerminalColor(theme.foreground) ?? { r: 255, g: 255, b: 255 };
|
||||
return {
|
||||
// TerminalViewport enables blinking only while its input owns focus.
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar' as const,
|
||||
fontSize,
|
||||
fontFamily: augmentedFontFamily,
|
||||
allowTransparency: false,
|
||||
theme: {
|
||||
background: theme.background,
|
||||
foreground: theme.foreground,
|
||||
cursor: theme.cursor,
|
||||
cursorAccent: theme.cursorAccent,
|
||||
selectionBackground: theme.selectionBackground,
|
||||
selectionForeground: theme.selectionForeground,
|
||||
black: theme.black,
|
||||
red: theme.red,
|
||||
green: theme.green,
|
||||
yellow: theme.yellow,
|
||||
blue: theme.blue,
|
||||
magenta: theme.magenta,
|
||||
cyan: theme.cyan,
|
||||
white: theme.white,
|
||||
brightBlack: theme.brightBlack,
|
||||
brightRed: theme.brightRed,
|
||||
brightGreen: theme.brightGreen,
|
||||
brightYellow: theme.brightYellow,
|
||||
brightBlue: theme.brightBlue,
|
||||
brightMagenta: theme.brightMagenta,
|
||||
brightCyan: theme.brightCyan,
|
||||
brightWhite: theme.brightWhite,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
ghostty,
|
||||
disableStdin,
|
||||
background,
|
||||
foreground,
|
||||
cursor: parseTerminalColor(theme.cursor) ?? foreground,
|
||||
palette: ANSI_ORDER.map((name) => parseTerminalColor(theme[name]) ?? foreground),
|
||||
selectionBackground: theme.selectionBackground,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getTerminalCellFromPoint,
|
||||
getTerminalWordRange,
|
||||
} from './terminalTouchSelection';
|
||||
|
||||
describe('terminal touch selection', () => {
|
||||
test('maps touch points to clamped terminal cells', () => {
|
||||
const bounds = { left: 20, top: 40, width: 800, height: 240 };
|
||||
|
||||
expect(getTerminalCellFromPoint(425, 165, bounds, 80, 24)).toEqual({ column: 40, row: 12 });
|
||||
expect(getTerminalCellFromPoint(-100, 500, bounds, 80, 24)).toEqual({ column: 0, row: 23 });
|
||||
expect(getTerminalCellFromPoint(20, 40, { ...bounds, width: 0 }, 80, 24)).toBeNull();
|
||||
});
|
||||
|
||||
test('selects the non-whitespace token around a long press', () => {
|
||||
expect(getTerminalWordRange(Array.from(' /projects/openchamber '), 10)).toEqual({
|
||||
startColumn: 2,
|
||||
endColumn: 22,
|
||||
});
|
||||
expect(getTerminalWordRange(Array.from('foo bar'), 3)).toEqual({ startColumn: 3, endColumn: 3 });
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
export type TerminalCellPosition = {
|
||||
column: number;
|
||||
row: number;
|
||||
};
|
||||
|
||||
type TerminalViewportRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const getTerminalCellFromPoint = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
bounds: TerminalViewportRect,
|
||||
columns: number,
|
||||
rows: number,
|
||||
): TerminalCellPosition | null => {
|
||||
if (bounds.width <= 0 || bounds.height <= 0 || columns <= 0 || rows <= 0) return null;
|
||||
|
||||
const column = Math.floor(((clientX - bounds.left) / bounds.width) * columns);
|
||||
const row = Math.floor(((clientY - bounds.top) / bounds.height) * rows);
|
||||
|
||||
return {
|
||||
column: Math.max(0, Math.min(columns - 1, column)),
|
||||
row: Math.max(0, Math.min(rows - 1, row)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getTerminalWordRange = (
|
||||
cells: string[],
|
||||
column: number,
|
||||
): { startColumn: number; endColumn: number } => {
|
||||
const clampedColumn = Math.max(0, Math.min(cells.length - 1, column));
|
||||
const isWordCell = (value: string | undefined) => Boolean(value && !/^\s+$/u.test(value));
|
||||
|
||||
if (!isWordCell(cells[clampedColumn])) {
|
||||
return { startColumn: clampedColumn, endColumn: clampedColumn };
|
||||
}
|
||||
|
||||
let startColumn = clampedColumn;
|
||||
let endColumn = clampedColumn;
|
||||
while (startColumn > 0 && isWordCell(cells[startColumn - 1])) startColumn -= 1;
|
||||
while (endColumn < cells.length - 1 && isWordCell(cells[endColumn + 1])) endColumn += 1;
|
||||
|
||||
return { startColumn, endColumn };
|
||||
};
|
||||
@@ -34,7 +34,7 @@ export type TerminalBuffer = {
|
||||
lastSequence: number;
|
||||
};
|
||||
|
||||
export const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({
|
||||
const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({
|
||||
chunks: Object.freeze([]) as unknown as TerminalChunk[],
|
||||
byteLength: 0,
|
||||
lastSequence: -1,
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
export {};
|
||||
|
||||
declare module 'ghostty-web' {
|
||||
export interface ITerminalOptions {
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
lineHeight?: number;
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -1,7 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__openchamberEnsureNerdFonts?: () => Promise<void>;
|
||||
__opencodeDebug?: {
|
||||
getLastAssistantMessage: () => unknown;
|
||||
getAllMessages: (truncate?: boolean) => unknown[];
|
||||
|
||||
@@ -593,44 +593,6 @@
|
||||
}, 10000);
|
||||
</script>
|
||||
|
||||
<!-- CSS Font Loading API for reliable Nerd Font loading. The ~2 MB of
|
||||
Nerd Fonts are only used by the terminal viewport, so they are no
|
||||
longer downloaded on every cold start: the terminal requests them on
|
||||
first mount through this idempotent hook. -->
|
||||
<script>
|
||||
(function() {
|
||||
let nerdFontsPromise = null;
|
||||
window.__openchamberEnsureNerdFonts = function() {
|
||||
if (nerdFontsPromise) {
|
||||
return nerdFontsPromise;
|
||||
}
|
||||
const fonts = [
|
||||
{
|
||||
name: 'JetBrainsMono Nerd Font',
|
||||
url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/JetBrainsMonoNerdFont-Regular.woff2'
|
||||
},
|
||||
{
|
||||
name: 'FiraCode Nerd Font',
|
||||
url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2'
|
||||
}
|
||||
];
|
||||
|
||||
const fontPromises = fonts.map(font => {
|
||||
const fontFace = new FontFace(font.name, `url(${font.url}) format('woff2')`);
|
||||
document.fonts.add(fontFace);
|
||||
return fontFace.load().catch(err => {
|
||||
console.warn(`Failed to load font: ${font.name}`, err);
|
||||
});
|
||||
});
|
||||
|
||||
nerdFontsPromise = Promise.allSettled(fontPromises).then(() => {
|
||||
document.documentElement.classList.add('fonts-loaded');
|
||||
});
|
||||
return nerdFontsPromise;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Polyfill for process before loading React -->
|
||||
<script>
|
||||
if (typeof process === 'undefined') {
|
||||
|
||||
@@ -77,7 +77,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",
|
||||
|
||||
@@ -73,7 +73,7 @@ export default defineConfig({
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,otf,eot}'],
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,otf,eot,wasm}'],
|
||||
// iOS Safari/PWA is much more reliable with a classic (non-module) SW bundle.
|
||||
rollupFormat: 'iife',
|
||||
// We already keep a custom manifest in index.html
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
diff --git a/node_modules/ghostty-web/dist/ghostty-web.js b/node_modules/ghostty-web/dist/ghostty-web.js
|
||||
index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644
|
||||
--- a/node_modules/ghostty-web/dist/ghostty-web.js
|
||||
+++ b/node_modules/ghostty-web/dist/ghostty-web.js
|
||||
@@ -1844,7 +1844,7 @@
|
||||
A.flags & G.FAINT && (this.ctx.globalAlpha = 0.5);
|
||||
const s = C, h = I + this.metrics.baseline;
|
||||
let N;
|
||||
- if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, Q) : N = String.fromCodePoint(A.codepoint || 32), this.ctx.fillText(N, s, h), A.flags & G.FAINT && (this.ctx.globalAlpha = 1), A.flags & G.UNDERLINE) {
|
||||
+ if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, Q) : N = A.codepoint == null || A.codepoint <= 0 || A.codepoint > 1114111 || A.codepoint >= 55296 && A.codepoint <= 57343 ? " " : String.fromCodePoint(A.codepoint), this.renderBlockChar(A.codepoint || 32, C, I, D) || this.ctx.fillText(N, s, h), A.flags & G.FAINT && (this.ctx.globalAlpha = 1), A.flags & G.UNDERLINE) {
|
||||
const t = I + this.metrics.baseline + 2;
|
||||
this.ctx.strokeStyle = this.ctx.fillStyle, this.ctx.lineWidth = 1, this.ctx.beginPath(), this.ctx.moveTo(C, t), this.ctx.lineTo(C + D, t), this.ctx.stroke();
|
||||
}
|
||||
@@ -1864,6 +1864,41 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
+ renderBlockChar(A, B, g, E) {
|
||||
+ const C = this.metrics.height, I = E / 2, D = C / 2;
|
||||
+ switch (A) {
|
||||
+ case 9600: this.ctx.fillRect(B, g, E, D); return !0;
|
||||
+ case 9601: this.ctx.fillRect(B, g + C * 7 / 8, E, C / 8); return !0;
|
||||
+ case 9602: this.ctx.fillRect(B, g + C * 3 / 4, E, C / 4); return !0;
|
||||
+ case 9603: this.ctx.fillRect(B, g + C * 5 / 8, E, C * 3 / 8); return !0;
|
||||
+ case 9604: this.ctx.fillRect(B, g + D, E, D); return !0;
|
||||
+ case 9605: this.ctx.fillRect(B, g + C * 3 / 8, E, C * 5 / 8); return !0;
|
||||
+ case 9606: this.ctx.fillRect(B, g + C / 4, E, C * 3 / 4); return !0;
|
||||
+ case 9607: this.ctx.fillRect(B, g + C / 8, E, C * 7 / 8); return !0;
|
||||
+ case 9608: this.ctx.fillRect(B, g, E, C); return !0;
|
||||
+ case 9609: this.ctx.fillRect(B, g, E * 7 / 8, C); return !0;
|
||||
+ case 9610: this.ctx.fillRect(B, g, E * 3 / 4, C); return !0;
|
||||
+ case 9611: this.ctx.fillRect(B, g, E * 5 / 8, C); return !0;
|
||||
+ case 9612: this.ctx.fillRect(B, g, I, C); return !0;
|
||||
+ case 9613: this.ctx.fillRect(B, g, E * 3 / 8, C); return !0;
|
||||
+ case 9614: this.ctx.fillRect(B, g, E / 4, C); return !0;
|
||||
+ case 9615: this.ctx.fillRect(B, g, E / 8, C); return !0;
|
||||
+ case 9616: this.ctx.fillRect(B + I, g, I, C); return !0;
|
||||
+ case 9620: this.ctx.fillRect(B, g, E, C / 8); return !0;
|
||||
+ case 9621: this.ctx.fillRect(B + E * 7 / 8, g, E / 8, C); return !0;
|
||||
+ case 9622: this.ctx.fillRect(B, g + D, I, D); return !0;
|
||||
+ case 9623: this.ctx.fillRect(B + I, g + D, I, D); return !0;
|
||||
+ case 9624: this.ctx.fillRect(B, g, I, D); return !0;
|
||||
+ case 9625: this.ctx.fillRect(B, g, I, C); this.ctx.fillRect(B + I, g + D, I, D); return !0;
|
||||
+ case 9626: this.ctx.fillRect(B, g, I, D); this.ctx.fillRect(B + I, g + D, I, D); return !0;
|
||||
+ case 9627: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B, g + D, I, D); return !0;
|
||||
+ case 9628: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B + I, g + D, I, D); return !0;
|
||||
+ case 9629: this.ctx.fillRect(B + I, g, I, D); return !0;
|
||||
+ case 9630: this.ctx.fillRect(B + I, g, I, D); this.ctx.fillRect(B, g + D, I, D); return !0;
|
||||
+ case 9631: this.ctx.fillRect(B + I, g, I, C); this.ctx.fillRect(B, g + D, I, D); return !0;
|
||||
+ default: return !1;
|
||||
+ }
|
||||
+ }
|
||||
/**
|
||||
* Render cursor
|
||||
*/
|
||||
Reference in New Issue
Block a user