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:
Bohdan Triapitsyn
2026-09-07 12:18:22 +03:00
parent 3132d1361a
commit 39fa8c1917
61 changed files with 6678 additions and 990 deletions
@@ -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)}
/>
);
});