Files
openchamber/packages/ui/src/components/terminal/TerminalViewport.tsx
T
Bohdan Triapitsyn c2f36fb5e7 fix(terminal): replay snapshot history at the PTY size it was drawn for
Opening the terminal panel sometimes showed stray fragments on the prompt
row: zsh's end-of-line mark and pieces of the prompt path. The shell had
laid its output out for one PTY width, but the client replayed that
history into an emulator of another width (an early size estimate, a
remount, or a renderer rebuild after fonts loaded). ghostty-web's reflow
then left fragments the shell's SIGWINCH redraw never clears.

The server now reports the PTY cols/rows in every snapshot, the transport
carries them through projections and accepted resizes, and the viewport
replays a sized snapshot chunk at that size before returning to the
fitted size. The container-based size estimate only seeds newly spawned
shells and is no longer sent to a running PTY.

Tests cover the sized replay, the store chunk size, the transport
projection, and the server snapshot; verified in a production build by
reloading with the panel open and switching tabs at a changed width.
2026-09-06 22:57:43 +03:00

595 lines
24 KiB
TypeScript

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 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
// 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(),
}));
// 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);
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 };
};
type TerminalSize = { cols: number; rows: number };
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');
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;
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)),
};
};
export type TerminalController = {
focus: () => void;
fit: () => void;
getSelection: () => { text: string; startLine: number; endLine: number } | null;
};
type Props = {
sessionKey: string;
chunks: TerminalChunk[];
onInput: (data: string) => void;
/** Fitted size: the emulator has this size, so the PTY should follow. */
onResize: (cols: number, rows: number) => void;
/**
* Size estimated from the container before Ghostty has measured anything.
* Good enough to spawn a shell early, not authoritative: an existing PTY
* must not be resized to it. Falls back to `onResize` when omitted.
*/
onProvisionalSize?: (cols: number, rows: number) => void;
theme: TerminalTheme;
monoFont: MonoFontOption;
fontFamily: string;
fontSize: number;
className?: string;
enableTouchScroll?: boolean;
autoFocus?: boolean;
isVisible?: boolean;
};
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className,
enableTouchScroll = false, autoFocus = true, isVisible = true,
}, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
const fitRef = React.useRef<FitAddon | 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 [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);
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);
}
}, []);
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;
};
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]) => {
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);
}
});
}
});
return () => {
disposed = true;
// Removing a focused editable mid-IME-composition wedges Android
// WebView's input dispatch (the whole app stops responding to touch).
// Blur first so the IME detaches cleanly, and hide the soft keyboard
// explicitly on Android before the terminal DOM is torn down.
const active = document.activeElement;
if (active instanceof HTMLElement && container.contains(active)) {
active.blur();
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
if (capacitor?.getPlatform?.() === 'android') {
void import('@capacitor/keyboard')
.then(({ Keyboard }) => Keyboard.hide())
.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;
lastChunkRef.current = null;
writeQueueRef.current = '';
outputRewriteCarryRef.current = '';
writingRef.current = false;
writeEpochRef.current += 1;
rendererReadyRef.current = false;
};
}, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
React.useEffect(() => {
const terminal = terminalRef.current;
const container = containerRef.current;
if (!terminal || !container) return;
terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement);
}, [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 { 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('');
lastChunkRef.current = chunks.at(-1)?.id ?? null;
flush();
}, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]);
React.useEffect(() => {
if (!autoFocus || !isVisible) return;
const frame = requestAnimationFrame(() => terminalRef.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;
let pointerId: number | null = null;
let longPressTimeout: ReturnType<typeof setTimeout> | null = null;
let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle';
let startX = 0;
let startY = 0;
let lastY = 0;
let remainder = 0;
let selectionFocus: TerminalCellPosition | null = null;
const lineHeight = Math.max(12, fontSize + 2);
// 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 = () => {
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
if (capacitor?.getPlatform?.() !== 'android') return;
void import('@capacitor/keyboard')
.then(({ Keyboard }) => Keyboard.show())
.catch(() => undefined);
};
const clearLongPress = () => {
if (!longPressTimeout) return;
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;
gesture = 'pending';
startX = event.clientX;
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);
}, 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);
}
if (event.cancelable) event.preventDefault();
return;
}
if (gesture === 'pending') {
const distance = Math.hypot(event.clientX - startX, event.clientY - startY);
if (distance < 8) return;
clearLongPress();
gesture = 'scrolling';
}
if (gesture !== 'scrolling') return;
const delta = lastY - event.clientY;
lastY = event.clientY;
remainder += delta;
const lines = Math.trunc(remainder / lineHeight);
if (lines) { terminal.scrollLines(lines); remainder -= lines * lineHeight; }
if (event.cancelable) event.preventDefault();
};
const up = (event: PointerEvent) => {
if (pointerId !== event.pointerId) return;
const shouldFocus = gesture === 'pending';
const shouldFinishSelection = gesture === 'selecting';
clearLongPress();
if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId);
pointerId = null;
gesture = 'idle';
if (shouldFinishSelection) finishSelection();
if (shouldFocus) {
terminal.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();
};
container.addEventListener('pointerdown', down);
container.addEventListener('pointermove', move, { passive: false });
container.addEventListener('pointerup', up);
container.addEventListener('pointercancel', cancel);
return () => {
clearLongPress();
container.removeEventListener('pointerdown', down);
container.removeEventListener('pointermove', move);
container.removeEventListener('pointerup', up);
container.removeEventListener('pointercancel', cancel);
};
}, [enableTouchScroll, fontSize, ready]);
React.useImperativeHandle(ref, () => ({
focus: () => terminalRef.current?.focus(),
fit,
getSelection: () => {
const terminal = terminalRef.current;
const range = terminal?.getSelectionPosition();
const text = terminal?.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)}
/>
);
});
TerminalViewport.displayName = 'TerminalViewport';
export { TerminalViewport };