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:
committed by
GitHub
parent
dca8be01ca
commit
110b0e5d8f
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
export {};
|
||||
|
||||
declare module 'ghostty-web' {
|
||||
export interface ITerminalOptions {
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
lineHeight?: number;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user