feat(terminal) refactoring and stability improvements (#98)

* feat(terminal): replace xterm with ghostty-web

Replace xterm.js terminal with ghostty-web implementation
Add terminal serialization support for state restoration
Apply custom patches to ghostty-web for enhancements

* feat(terminal): add bun-pty backend support

Switch terminal to ghostty-web with bun-pty backend for better performance
Auto-detect and prefer Bun runtime when available for terminal sessions
Update terminal viewport write queue handling for improved reliability

* fix(terminal): prevent unnecessary resize events

Only report terminal resize when dimensions actually change
Simplify chunk processing state tracking
Disable terminal transparency for consistent rendering

* feat(terminal): increase scrollback and buffer limits

Increase terminal scrollback buffer from 10k to 50k lines
Increase terminal buffer limit from 256k to 1M bytes
Add rate limiting and improve output handling for terminal streams
This commit is contained in:
Bohdan Triapitsyn
2026-01-02 21:57:53 +02:00
committed by GitHub
parent dca8be01ca
commit 110b0e5d8f
36 changed files with 2417 additions and 821 deletions
@@ -1,14 +1,51 @@
import React from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { getTerminalOptions } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import type { TerminalChunk } from '@/stores/useTerminalStore';
import { cn } from '@/lib/utils';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
let ghosttyPromise: Promise<Ghostty> | null = null;
function getGhostty(): Promise<Ghostty> {
if (!ghosttyPromise) {
ghosttyPromise = Ghostty.load();
}
return ghosttyPromise;
}
function findScrollableViewport(container: HTMLElement): HTMLElement | null {
if (typeof window === 'undefined') {
return null;
}
const candidates = [container, ...Array.from(container.querySelectorAll<HTMLElement>('*'))];
let fallback: HTMLElement | null = null;
for (const element of candidates) {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
if (overflowY !== 'auto' && overflowY !== 'scroll') {
continue;
}
// Prefer an element that is currently scrollable.
if (element.scrollHeight - element.clientHeight > 2) {
return element;
}
// Otherwise keep the first overflow container as a fallback so we can
// attach touch scroll before scrollback grows.
if (!fallback) {
fallback = element;
}
}
return fallback;
}
type TerminalController = {
focus: () => void;
clear: () => void;
@@ -34,25 +71,65 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const viewportRef = React.useRef<HTMLElement | null>(null);
const terminalRef = React.useRef<Terminal | null>(null);
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
const writeQueueRef = React.useRef<string[]>([]);
const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const pendingWriteRef = React.useRef('');
const writeScheduledRef = React.useRef<number | null>(null);
const isWritingRef = React.useRef(false);
const processedCountRef = React.useRef(0);
const firstChunkIdRef = React.useRef<number | null>(null);
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
const viewportDiscoveryAttemptsRef = React.useRef(0);
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
const [, forceRender] = React.useReducer((x) => x + 1, 0);
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
inputHandlerRef.current = onInput;
resizeHandlerRef.current = onResize;
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
const input = hiddenInputRef.current;
const container = containerRef.current;
if (!input || !container) {
return;
}
// Position the input near the user's tap/cursor so the global keyboard
// avoidance logic can decide whether anything is actually obscured.
const rect = container.getBoundingClientRect();
const fallbackX = rect.left + rect.width / 2;
const fallbackY = rect.top + rect.height - 12;
const x = typeof clientX === 'number' ? clientX : fallbackX;
const y = typeof clientY === 'number' ? clientY : fallbackY;
const padding = 8;
const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left));
const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top));
input.style.left = `${left}px`;
input.style.top = `${top}px`;
input.style.bottom = '';
try {
input.focus({ preventScroll: true });
} catch {
try {
input.focus();
} catch { /* ignored */ }
}
}, []);
const resetWriteState = React.useCallback(() => {
writeQueueRef.current = [];
pendingWriteRef.current = '';
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(writeScheduledRef.current);
}
writeScheduledRef.current = null;
isWritingRef.current = false;
processedCountRef.current = 0;
firstChunkIdRef.current = null;
lastProcessedChunkIdRef.current = null;
}, []);
const fitTerminal = React.useCallback(() => {
@@ -68,61 +145,85 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
try {
fitAddon.fit();
resizeHandlerRef.current(terminal.cols, terminal.rows);
const next = { cols: terminal.cols, rows: terminal.rows };
const previous = lastReportedSizeRef.current;
if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) {
lastReportedSizeRef.current = next;
resizeHandlerRef.current(next.cols, next.rows);
}
} catch { /* ignored */ }
}, []);
const flushWriteQueue = React.useCallback(() => {
const flushWrites = React.useCallback(() => {
if (isWritingRef.current) {
return;
}
const consumeNext = () => {
const term = terminalRef.current;
if (!term) {
resetWriteState();
return;
}
const term = terminalRef.current;
if (!term) {
resetWriteState();
return;
}
const chunk = writeQueueRef.current.shift();
if (chunk === undefined) {
isWritingRef.current = false;
return;
}
if (!pendingWriteRef.current) {
return;
}
isWritingRef.current = true;
term.write(chunk, () => {
isWritingRef.current = false;
if (writeQueueRef.current.length > 0) {
if (typeof window !== 'undefined') {
window.setTimeout(consumeNext, 0);
} else {
consumeNext();
}
const chunk = pendingWriteRef.current;
pendingWriteRef.current = '';
isWritingRef.current = true;
term.write(chunk, () => {
isWritingRef.current = false;
if (pendingWriteRef.current) {
if (typeof window !== 'undefined') {
writeScheduledRef.current = window.requestAnimationFrame(() => {
writeScheduledRef.current = null;
flushWrites();
});
} else {
flushWrites();
}
});
};
consumeNext();
}
});
}, [resetWriteState]);
const scheduleFlushWrites = React.useCallback(() => {
if (writeScheduledRef.current !== null) {
return;
}
if (typeof window !== 'undefined') {
writeScheduledRef.current = window.requestAnimationFrame(() => {
writeScheduledRef.current = null;
flushWrites();
});
} else {
flushWrites();
}
}, [flushWrites]);
const enqueueWrite = React.useCallback(
(data: string) => {
if (!data) {
return;
}
writeQueueRef.current = [data];
isWritingRef.current = false;
flushWriteQueue();
pendingWriteRef.current += data;
scheduleFlushWrites();
},
[flushWriteQueue]
[scheduleFlushWrites]
);
const setupTouchScroll = React.useCallback(() => {
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
if (!enableTouchScroll) {
viewportDiscoveryAttemptsRef.current = 0;
return;
}
@@ -131,11 +232,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (!viewport) {
// Ghostty scrollback is internal (canvas-based). On touch devices we need
// to translate touch deltas into terminal scroll calls.
const terminal = terminalRef.current;
if (!terminal) {
return;
}
viewportDiscoveryAttemptsRef.current = 0;
const baseScrollMultiplier = 2.2;
const maxScrollBoost = 2.8;
const boostDenominator = 25;
@@ -149,24 +254,35 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
lastTime: null as number | null,
velocity: 0,
rafId: null as number | null,
startX: null as number | null,
startY: null as number | null,
didMove: false,
};
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight);
const setScrollTop = (nextScrollTop: number) => {
const maxScrollTop = getMaxScrollTop();
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
};
const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35));
let remainderPx = 0;
const scrollByPixels = (deltaPixels: number) => {
if (!deltaPixels) {
return;
return false;
}
const previous = viewport.scrollTop;
setScrollTop(previous + deltaPixels);
return viewport.scrollTop !== previous;
const before = terminal.getViewportY();
const total = remainderPx + deltaPixels;
const lines = Math.trunc(total / lineHeightPx);
remainderPx = total - lines * lineHeightPx;
if (lines !== 0) {
// Touch delta is in pixels, convert to lines.
// Natural mobile scrolling: finger up scrolls down.
terminal.scrollLines(lines);
}
const after = terminal.getViewportY();
return after !== before;
};
const stopKinetic = () => {
@@ -176,11 +292,18 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.rafId = null;
};
const listenerOptions: AddEventListenerOptions = { passive: false, capture: true };
const listenerOptions: AddEventListenerOptions = { passive: false, capture: false };
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
if (supportsPointerEvents) {
const stateWithPointerId = Object.assign(state, { pointerId: null as number | null });
const stateWithPointerId = Object.assign(state, {
pointerId: null as number | null,
startX: null as number | null,
startY: null as number | null,
moved: false,
});
const TAP_MOVE_THRESHOLD_PX = 6;
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
@@ -188,6 +311,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
stopKinetic();
stateWithPointerId.pointerId = event.pointerId;
stateWithPointerId.startX = event.clientX;
stateWithPointerId.startY = event.clientY;
stateWithPointerId.moved = false;
stateWithPointerId.lastY = event.clientY;
stateWithPointerId.lastTime = nowMs();
stateWithPointerId.velocity = 0;
@@ -201,6 +327,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) {
const dx = event.clientX - stateWithPointerId.startX;
const dy = event.clientY - stateWithPointerId.startY;
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
stateWithPointerId.moved = true;
}
}
if (stateWithPointerId.lastY === null) {
stateWithPointerId.lastY = event.clientY;
stateWithPointerId.lastTime = nowMs();
@@ -230,10 +364,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
stateWithPointerId.velocity = -maxVelocity;
}
if (event.cancelable) {
event.preventDefault();
// Only prevent default once we're actually scrolling.
if (stateWithPointerId.moved) {
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
}
event.stopPropagation();
scrollByPixels(deltaPixels);
};
@@ -241,13 +379,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
return;
}
const wasTap = !stateWithPointerId.moved;
stateWithPointerId.pointerId = null;
stateWithPointerId.startX = null;
stateWithPointerId.startY = null;
stateWithPointerId.moved = false;
stateWithPointerId.lastY = null;
stateWithPointerId.lastTime = null;
try {
container.releasePointerCapture(event.pointerId);
} catch { /* ignored */ }
if (wasTap) {
focusHiddenInput(event.clientX, event.clientY);
return;
}
if (typeof window === 'undefined') {
return;
}
@@ -287,10 +436,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none';
container.style.touchAction = 'manipulation';
touchScrollCleanupRef.current = () => {
stopKinetic();
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
viewportDiscoveryAttemptsRef.current = 0;
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
@@ -301,6 +455,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const TAP_MOVE_THRESHOLD_PX = 6;
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
@@ -309,6 +465,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.lastY = event.touches[0].clientY;
state.lastTime = nowMs();
state.velocity = 0;
state.startX = event.touches[0].clientX;
state.startY = event.touches[0].clientY;
state.didMove = false;
};
const handleTouchMove = (event: TouchEvent) => {
@@ -316,11 +475,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.lastY = null;
state.lastTime = null;
state.velocity = 0;
state.startX = null;
state.startY = null;
state.didMove = false;
stopKinetic();
return;
}
const currentX = event.touches[0].clientX;
const currentY = event.touches[0].clientY;
if (state.startX !== null && state.startY !== null && !state.didMove) {
const dx = currentX - state.startX;
const dy = currentY - state.startY;
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
state.didMove = true;
}
}
if (state.lastY === null) {
state.lastY = currentY;
state.lastTime = nowMs();
@@ -350,20 +522,37 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.velocity = -maxVelocity;
}
event.preventDefault();
event.stopPropagation();
if (state.didMove) {
event.preventDefault();
event.stopPropagation();
}
scrollByPixels(deltaPixels);
};
const handleTouchEnd = () => {
const handleTouchEnd = (event: TouchEvent) => {
const wasTap = !state.didMove;
state.lastY = null;
state.lastTime = null;
const velocity = state.velocity;
state.startX = null;
state.startY = null;
state.didMove = false;
if (wasTap) {
const point = event.changedTouches?.[0];
focusHiddenInput(point?.clientX, point?.clientY);
return;
}
if (typeof window === 'undefined') {
return;
}
if (Math.abs(state.velocity) < minVelocity) {
if (Math.abs(velocity) < minVelocity) {
state.velocity = 0;
return;
}
@@ -394,77 +583,114 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
container.addEventListener('touchend', handleTouchEnd, listenerOptions);
container.addEventListener('touchcancel', handleTouchEnd, listenerOptions);
container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none';
container.style.touchAction = 'manipulation';
touchScrollCleanupRef.current = () => {
stopKinetic();
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
viewportDiscoveryAttemptsRef.current = 0;
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
container.removeEventListener('touchend', handleTouchEnd, listenerOptions);
container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions);
container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
container.style.touchAction = previousTouchAction;
};
}, [enableTouchScroll]);
}, [enableTouchScroll, focusHiddenInput, fontSize]);
React.useEffect(() => {
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
const fitAddon = new FitAddon();
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
let disposed = false;
let localTerminal: GhosttyTerminal | null = null;
let localResizeObserver: ResizeObserver | null = null;
let localDisposables: Array<{ dispose: () => void }> = [];
const container = containerRef.current;
if (container) {
terminal.open(container);
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (viewport) {
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
viewportRef.current = viewport;
forceRender();
}
fitTerminal();
terminal.focus();
}
const disposables = [
terminal.onData((data) => {
inputHandlerRef.current(data);
}),
];
const resizeObserver = new ResizeObserver(() => {
fitTerminal();
});
if (container) {
resizeObserver.observe(container);
}
return () => {
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
disposables.forEach((disposable) => disposable.dispose());
resizeObserver.disconnect();
terminal.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
resetWriteState();
};
}, [fitTerminal, fontFamily, fontSize, theme, resetWriteState]);
React.useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
if (!container) {
return;
}
const options = getTerminalOptions(fontFamily, fontSize, theme);
Object.assign(terminal.options as Record<string, unknown>, options);
fitTerminal();
}, [fitTerminal, fontFamily, fontSize, theme]);
container.tabIndex = 0;
const initialize = async () => {
try {
const ghostty = await getGhostty();
if (disposed) {
return;
}
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty);
const terminal = new GhosttyTerminal(options);
const fitAddon = new FitAddon();
localTerminal = terminal;
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.open(container);
bumpTerminalReady();
const viewport = findScrollableViewport(container);
if (viewport) {
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
viewportRef.current = viewport;
forceRender();
} else {
viewportRef.current = null;
}
fitTerminal();
setupTouchScroll();
terminal.focus();
localDisposables = [
terminal.onData((data: string) => {
inputHandlerRef.current(data);
}),
];
localResizeObserver = new ResizeObserver(() => {
fitTerminal();
});
localResizeObserver.observe(container);
if (typeof window !== 'undefined') {
window.setTimeout(() => {
fitTerminal();
}, 0);
}
} catch {
// ignored
}
};
void initialize();
return () => {
disposed = true;
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
localDisposables.forEach((disposable) => disposable.dispose());
localResizeObserver?.disconnect();
localTerminal?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
viewportRef.current = null;
lastReportedSizeRef.current = null;
resetWriteState();
};
}, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
React.useEffect(() => {
const terminal = terminalRef.current;
@@ -473,9 +699,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
terminal.reset();
resetWriteState();
lastReportedSizeRef.current = null;
fitTerminal();
terminal.focus();
}, [sessionKey, fitTerminal, resetWriteState]);
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
React.useEffect(() => {
setupTouchScroll();
@@ -492,7 +719,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
if (chunks.length === 0) {
if (processedCountRef.current !== 0) {
if (lastProcessedChunkIdRef.current !== null) {
terminal.reset();
resetWriteState();
fitTerminal();
@@ -500,31 +727,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const currentFirstId = chunks[0].id;
if (firstChunkIdRef.current === null) {
firstChunkIdRef.current = currentFirstId;
const lastProcessedId = lastProcessedChunkIdRef.current;
let pending: TerminalChunk[];
if (lastProcessedId === null) {
pending = chunks;
} else {
const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId);
pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks;
}
const shouldReset =
firstChunkIdRef.current !== currentFirstId || processedCountRef.current > chunks.length;
if (shouldReset) {
terminal.reset();
resetWriteState();
firstChunkIdRef.current = currentFirstId;
}
if (processedCountRef.current < chunks.length) {
const pending = chunks.slice(processedCountRef.current);
if (pending.length > 0) {
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
processedCountRef.current = chunks.length;
}
}, [chunks, enqueueWrite, fitTerminal, resetWriteState]);
lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id;
}, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]);
React.useImperativeHandle(
ref,
(): TerminalController => ({
focus: () => {
if (enableTouchScroll) {
focusHiddenInput();
return;
}
terminalRef.current?.focus();
},
clear: () => {
@@ -540,12 +767,69 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal();
},
}),
[fitTerminal, resetWriteState]
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
);
return (
<div ref={containerRef} className={cn('relative h-full w-full', className)}>
{viewportRef.current ? (
<div
ref={containerRef}
className={cn('relative h-full w-full', className)}
style={{ backgroundColor: theme.background }}
onClick={(event) => {
if (enableTouchScroll) {
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
}
}}
>
{enableTouchScroll ? (
<textarea
ref={hiddenInputRef}
inputMode="text"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
tabIndex={-1}
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1,
height: 1,
opacity: 0.001,
zIndex: 1,
background: 'transparent',
color: 'transparent',
border: 'none',
padding: 0,
margin: 0,
outline: 'none',
}}
onInput={(event) => {
const raw = String(event.currentTarget.value || '');
if (!raw) {
return;
}
// iOS often inserts `\n` for Enter; the PTY expects CR.
const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
event.currentTarget.value = '';
}}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
// If there's nothing in the input buffer, emulate DEL.
if (!event.currentTarget.value) {
inputHandlerRef.current('\x7f');
}
}
}}
/>
) : null}
{viewportRef.current && !enableTouchScroll ? (
<OverlayScrollbar
containerRef={viewportRef}
disableHorizontal
@@ -190,6 +190,11 @@ export const TerminalView: React.FC = () => {
switch (event.type) {
case 'connected': {
if (event.runtime || event.ptyBackend) {
console.log(
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
);
}
setConnecting(directory, false);
setConnectionError(null);
setIsFatalError(false);
+3
View File
@@ -42,6 +42,9 @@ export interface TerminalStreamEvent {
signal?: number | null;
attempt?: number;
maxAttempts?: number;
runtime?: 'node' | 'bun';
ptyBackend?: string;
}
export interface CreateTerminalOptions {
@@ -0,0 +1,497 @@
/**
* SerializeAddon for ghostty-web
*
* Port of xterm.js addon-serialize for ghostty-web terminal.
* Enables serialization of terminal contents to restore state after reconnection.
*
* Features:
* - ANSI color preservation (16-color, 256-color, RGB)
* - Text attributes (bold, italic, underline, faint, strikethrough, blink, inverse, invisible, dim)
* - Scrollback support with configurable limits
* - Round-trip compatibility
* - Cursor positioning
*/
import type { Terminal as GhosttyTerminal } from 'ghostty-web';
// Constants for ANSI escape codes
const C0 = {
ESC: '\u001b',
};
const SGR = {
RESET: 0,
BOLD: 1,
DIM: 2,
ITALIC: 3,
UNDERLINE: 4,
SLOW_BLINK: 5,
RAPID_BLINK: 6,
INVERSE: 7,
INVISIBLE: 8,
STRIKETHROUGH: 9,
NORMAL_INTENSITY: 22,
NO_ITALIC: 23,
NO_UNDERLINE: 24,
NO_BLINK: 25,
NO_INVERSE: 27,
VISIBLE: 28,
NO_STRIKETHROUGH: 29,
FG_DEFAULT: 39,
BG_DEFAULT: 49,
};
export interface SerializeOptions {
/**
* The row range to serialize. When an explicit range is specified, the cursor
* will get its final repositioning.
*/
range?: {
start: number;
end: number;
};
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer. When not specified, all available
* rows in the scrollback buffer will be serialized.
*/
scrollback?: number;
/**
* Whether to exclude the terminal modes from the serialization.
* Default: false
*/
excludeModes?: boolean;
/**
* Whether to exclude the alt buffer from the serialization.
* Default: false
*/
excludeAltBuffer?: boolean;
}
export interface TextSerializeOptions {
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer.
*/
scrollback?: number;
/**
* Whether to trim trailing whitespace from lines.
* Default: true
*/
trimWhitespace?: boolean;
}
interface CellState {
fg: number | null;
bg: number | null;
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
blink: boolean;
inverse: boolean;
invisible: boolean;
strikethrough: boolean;
}
const NULL_CELL_STATE: CellState = {
fg: null,
bg: null,
bold: false,
dim: false,
italic: false,
underline: false,
blink: false,
inverse: false,
invisible: false,
strikethrough: false,
};
/**
* SerializeAddon for ghostty-web terminal
*/
export class SerializeAddon {
private _terminal: GhosttyTerminal | undefined;
/**
* Activate the addon
*/
activate(terminal: GhosttyTerminal): void {
this._terminal = terminal;
}
/**
* Dispose the addon
*/
dispose(): void {
this._terminal = undefined;
}
/**
* Serialize the terminal buffer to ANSI escape sequences
*/
serialize(options: SerializeOptions = {}): string {
if (!this._terminal) {
throw new Error('SerializeAddon not activated');
}
const buffer = this._terminal.buffer.active;
if (!buffer) {
return '';
}
const result: string[] = [];
let currentState: CellState = { ...NULL_CELL_STATE };
// Determine range to serialize
const scrollbackLimit = options.scrollback ?? buffer.length;
let startRow: number;
let endRow: number;
if (options.range) {
startRow = options.range.start;
endRow = options.range.end;
} else {
// Serialize scrollback + viewport
const totalRows = buffer.length;
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
startRow = Math.max(0, buffer.baseY - scrollbackRows);
endRow = buffer.baseY + buffer.cursorY;
}
// Clamp to valid range
startRow = Math.max(0, startRow);
endRow = Math.min(buffer.length - 1, endRow);
for (let y = startRow; y <= endRow; y++) {
const line = buffer.getLine(y);
if (!line) {
result.push('\r\n');
continue;
}
let lineContent = '';
let lastNonSpaceCol = -1;
// Find the last non-space column
for (let x = line.length - 1; x >= 0; x--) {
const cell = line.getCell(x);
if (cell) {
const char = this._getCellChar(cell);
if (char !== ' ' && char !== '') {
lastNonSpaceCol = x;
break;
}
}
}
// Serialize each cell up to the last non-space
for (let x = 0; x <= lastNonSpaceCol; x++) {
const cell = line.getCell(x);
if (!cell) {
lineContent += ' ';
continue;
}
// Get cell attributes and generate SGR sequences if needed
const newState = this._getCellState(cell);
const sgrSequences = this._generateSgrDiff(currentState, newState);
if (sgrSequences) {
lineContent += sgrSequences;
currentState = newState;
}
// Get character
const char = this._getCellChar(cell);
lineContent += char || ' ';
}
// Reset attributes at end of line if any were set
if (this._hasAttributes(currentState)) {
lineContent += `${C0.ESC}[${SGR.RESET}m`;
currentState = { ...NULL_CELL_STATE };
}
result.push(lineContent);
// Add newline unless it's the last row with cursor
if (y < endRow) {
result.push('\r\n');
}
}
// Position cursor
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY >= 0 && cursorX >= 0) {
// Use CUP (Cursor Position) to move cursor to correct position
// CUP is 1-based, so add 1 to both coordinates
const relativeY = cursorY - (endRow - buffer.baseY);
if (relativeY !== 0 || cursorX !== 0) {
result.push(`${C0.ESC}[${cursorY + 1};${cursorX + 1}H`);
}
}
return result.join('');
}
/**
* Serialize the terminal buffer to plain text (no escape sequences)
*/
serializeAsText(options: TextSerializeOptions = {}): string {
if (!this._terminal) {
throw new Error('SerializeAddon not activated');
}
const buffer = this._terminal.buffer.active;
if (!buffer) {
return '';
}
const trimWhitespace = options.trimWhitespace ?? true;
const scrollbackLimit = options.scrollback ?? buffer.length;
const result: string[] = [];
// Determine range
const totalRows = buffer.length;
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
const startRow = Math.max(0, buffer.baseY - scrollbackRows);
const endRow = buffer.baseY + buffer.cursorY;
for (let y = startRow; y <= endRow; y++) {
const line = buffer.getLine(y);
if (!line) {
result.push('');
continue;
}
let lineContent = '';
for (let x = 0; x < line.length; x++) {
const cell = line.getCell(x);
if (cell) {
const char = this._getCellChar(cell);
lineContent += char || ' ';
} else {
lineContent += ' ';
}
}
if (trimWhitespace) {
lineContent = lineContent.trimEnd();
}
result.push(lineContent);
}
return result.join('\n');
}
/**
* Get the character from a cell, handling wide characters and special codepoints
*/
private _getCellChar(cell: { getChars?: () => string; getCodepoint?: () => number }): string {
// Try getChars() first (ghostty-web standard)
if (typeof cell.getChars === 'function') {
const chars = cell.getChars();
if (chars) return chars;
}
// Try getCodepoint()
if (typeof cell.getCodepoint === 'function') {
const codepoint = cell.getCodepoint();
if (codepoint && codepoint > 0 && codepoint <= 0x10FFFF &&
!(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
return String.fromCodePoint(codepoint);
}
}
// Fallback
return ' ';
}
/**
* Get the state of a cell (colors and attributes)
*/
private _getCellState(cell: {
getFgColor?: () => number;
getBgColor?: () => number;
isBold?: () => boolean | number;
isDim?: () => boolean | number;
isFaint?: () => boolean | number;
isItalic?: () => boolean | number;
isUnderline?: () => boolean | number;
isBlink?: () => boolean | number;
isInverse?: () => boolean | number;
isInvisible?: () => boolean | number;
isStrikethrough?: () => boolean | number;
}): CellState {
const state: CellState = { ...NULL_CELL_STATE };
// Get foreground color
if (typeof cell.getFgColor === 'function') {
const fg = cell.getFgColor();
if (fg !== undefined && fg !== null && fg !== -1) {
state.fg = fg;
}
}
// Get background color
if (typeof cell.getBgColor === 'function') {
const bg = cell.getBgColor();
if (bg !== undefined && bg !== null && bg !== -1) {
state.bg = bg;
}
}
// Get attributes
if (typeof cell.isBold === 'function') {
state.bold = !!cell.isBold();
}
if (typeof cell.isDim === 'function') {
state.dim = !!cell.isDim();
} else if (typeof cell.isFaint === 'function') {
state.dim = !!cell.isFaint();
}
if (typeof cell.isItalic === 'function') {
state.italic = !!cell.isItalic();
}
if (typeof cell.isUnderline === 'function') {
state.underline = !!cell.isUnderline();
}
if (typeof cell.isBlink === 'function') {
state.blink = !!cell.isBlink();
}
if (typeof cell.isInverse === 'function') {
state.inverse = !!cell.isInverse();
}
if (typeof cell.isInvisible === 'function') {
state.invisible = !!cell.isInvisible();
}
if (typeof cell.isStrikethrough === 'function') {
state.strikethrough = !!cell.isStrikethrough();
}
return state;
}
/**
* Generate SGR escape sequences for the difference between two cell states
*/
private _generateSgrDiff(from: CellState, to: CellState): string | null {
const codes: number[] = [];
// Check if we need a full reset
const needsReset =
(from.bold && !to.bold) ||
(from.dim && !to.dim) ||
(from.italic && !to.italic) ||
(from.underline && !to.underline) ||
(from.blink && !to.blink) ||
(from.inverse && !to.inverse) ||
(from.invisible && !to.invisible) ||
(from.strikethrough && !to.strikethrough);
if (needsReset) {
codes.push(SGR.RESET);
// After reset, we need to re-apply all 'to' attributes
if (to.bold) codes.push(SGR.BOLD);
if (to.dim) codes.push(SGR.DIM);
if (to.italic) codes.push(SGR.ITALIC);
if (to.underline) codes.push(SGR.UNDERLINE);
if (to.blink) codes.push(SGR.SLOW_BLINK);
if (to.inverse) codes.push(SGR.INVERSE);
if (to.invisible) codes.push(SGR.INVISIBLE);
if (to.strikethrough) codes.push(SGR.STRIKETHROUGH);
// Re-apply colors
if (to.fg !== null) {
this._appendColorCode(codes, to.fg, true);
}
if (to.bg !== null) {
this._appendColorCode(codes, to.bg, false);
}
} else {
// Apply only changed attributes
if (!from.bold && to.bold) codes.push(SGR.BOLD);
if (!from.dim && to.dim) codes.push(SGR.DIM);
if (!from.italic && to.italic) codes.push(SGR.ITALIC);
if (!from.underline && to.underline) codes.push(SGR.UNDERLINE);
if (!from.blink && to.blink) codes.push(SGR.SLOW_BLINK);
if (!from.inverse && to.inverse) codes.push(SGR.INVERSE);
if (!from.invisible && to.invisible) codes.push(SGR.INVISIBLE);
if (!from.strikethrough && to.strikethrough) codes.push(SGR.STRIKETHROUGH);
// Handle color changes
if (from.fg !== to.fg) {
if (to.fg === null) {
codes.push(SGR.FG_DEFAULT);
} else {
this._appendColorCode(codes, to.fg, true);
}
}
if (from.bg !== to.bg) {
if (to.bg === null) {
codes.push(SGR.BG_DEFAULT);
} else {
this._appendColorCode(codes, to.bg, false);
}
}
}
if (codes.length === 0) {
return null;
}
return `${C0.ESC}[${codes.join(';')}m`;
}
/**
* Append color code to the codes array
*/
private _appendColorCode(codes: number[], color: number, isForeground: boolean): void {
const base = isForeground ? 30 : 40;
const extBase = isForeground ? 38 : 48;
if (color < 8) {
// Basic 8 colors
codes.push(base + color);
} else if (color < 16) {
// Bright 8 colors
codes.push(base + 60 + (color - 8));
} else if (color < 256) {
// 256-color palette
codes.push(extBase, 5, color);
} else {
// RGB (24-bit) color encoded as 0xRRGGBB + 0x1000000
const rgb = color - 0x1000000;
const r = (rgb >> 16) & 0xFF;
const g = (rgb >> 8) & 0xFF;
const b = rgb & 0xFF;
codes.push(extBase, 2, r, g, b);
}
}
/**
* Check if the state has any attributes set
*/
private _hasAttributes(state: CellState): boolean {
return (
state.fg !== null ||
state.bg !== null ||
state.bold ||
state.dim ||
state.italic ||
state.underline ||
state.blink ||
state.inverse ||
state.invisible ||
state.strikethrough
);
}
}
+50 -1
View File
@@ -1,3 +1,4 @@
import type { Ghostty } from 'ghostty-web';
import type { Theme } from '@/types/theme';
export interface TerminalTheme {
@@ -79,7 +80,7 @@ export function getTerminalOptions(
cursorStyle: 'block' as const,
theme,
allowTransparency: false,
scrollback: 10000,
scrollback: 50_000,
minimumContrastRatio: 1,
fastScrollModifier: 'shift' as const,
fastScrollSensitivity: 5,
@@ -89,3 +90,51 @@ export function getTerminalOptions(
rightClickSelectsWord: true,
};
}
/**
* Get terminal options for Ghostty Web terminal
*/
export function getGhosttyTerminalOptions(
fontFamily: string,
fontSize: number,
theme: TerminalTheme,
ghostty: Ghostty
) {
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}`;
return {
cursorBlink: true,
fontSize,
lineHeight: 1.15,
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: 50_000,
ghostty,
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ interface TerminalStore {
clearAllTerminalSessions: () => void;
}
const TERMINAL_BUFFER_LIMIT = 256_000;
const TERMINAL_BUFFER_LIMIT = 1_000_000;
function normalizeDirectory(dir: string): string {
let normalized = dir.trim();
+11
View File
@@ -0,0 +1,11 @@
export {};
declare module 'ghostty-web' {
export interface ITerminalOptions {
lineHeight?: number;
}
export interface RendererOptions {
lineHeight?: number;
}
}