Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
useCallback,
useEffect,
useEffectEvent,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -324,6 +325,7 @@ interface TerminalViewportProps {
onAddTerminalContext: (selection: TerminalContextSelection) => void;
focusRequestId: number;
autoFocus: boolean;
visible: boolean;
resizeEpoch: number;
drawerHeight: number;
keybindings: ResolvedKeybindingsConfig;
Expand All @@ -348,12 +350,14 @@ export function TerminalViewport({
onAddTerminalContext,
focusRequestId,
autoFocus,
visible,
resizeEpoch,
drawerHeight,
keybindings,
}: TerminalViewportProps) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<GhosttyTerminalSurface | null>(null);
const visibleRef = useRef(visible);
const environmentId = threadRef.environmentId;
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
const openInPreferredEditor = useOpenInPreferredEditor(
Expand Down Expand Up @@ -465,6 +469,11 @@ export function TerminalViewport({
keybindingsRef.current = keybindings;
}, [keybindings]);

useLayoutEffect(() => {
visibleRef.current = visible;
terminalRef.current?.setVisible(visible);
}, [visible]);

useEffect(() => {
const current = terminalFontRef.current;
if (current.family === terminalFontFamily && current.size === terminalFontSize) return;
Expand All @@ -487,6 +496,9 @@ export function TerminalViewport({
const terminalOptions: GhosttyTerminalSurfaceOptions = {
theme: terminalThemeFromApp(mount),
font: terminalFontOptions(setupFont.family, setupFont.size),
get visible() {
return visibleRef.current;
},
onData: (data) => handleData(data),
onResize: (cols, rows) => void resizeTerminal(cols, rows),
onSelectionChange: () => handleSelectionChange(),
Expand All @@ -504,6 +516,7 @@ export function TerminalViewport({
terminal.dispose();
return null;
}
terminal.setVisible(visibleRef.current);
// The theme observer is not installed yet, so re-read the theme in case
// the app toggled light/dark while the WASM surface was loading.
terminal.setTheme(terminalThemeFromApp(mount));
Expand All @@ -526,7 +539,7 @@ export function TerminalViewport({
// never started, so only "exited" triggers the message — as with xterm.)
synchronizedStatusRef.current = "closed";
synchronizeTerminalStatus(terminal, latestSession.status);
if (autoFocus) window.requestAnimationFrame(() => terminal.focus());
if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus());

const clearSelectionAction = () => {
selectionActionRequestIdRef.current += 1;
Expand Down Expand Up @@ -940,7 +953,7 @@ export function TerminalViewport({
writeSystemMessage(terminal, current.error);
}

if (previous.version === 0 && autoFocus) {
if (previous.version === 0 && autoFocus && visibleRef.current) {
window.requestAnimationFrame(() => {
terminal.focus();
});
Expand All @@ -949,7 +962,7 @@ export function TerminalViewport({
}, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]);

useEffect(() => {
if (!autoFocus) return;
if (!autoFocus || !visible) return;
const terminal = terminalRef.current;
if (!terminal) return;
const frame = window.requestAnimationFrame(() => {
Expand All @@ -958,15 +971,16 @@ export function TerminalViewport({
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus, focusRequestId]);
}, [autoFocus, focusRequestId, visible]);

useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
if (!terminal || !visibleRef.current) return;
const wasAtBottom = terminal.isAtBottom();
// The surface reports grid changes through onResize, which is the single
// channel for PTY resize RPCs; fitting here only refreshes the layout.
const frame = window.requestAnimationFrame(() => {
if (!visibleRef.current) return;
terminal.fit();
if (wasAtBottom) {
terminal.scrollToBottom();
Expand Down Expand Up @@ -1542,6 +1556,7 @@ export default function ThreadTerminalDrawer({
onAddTerminalContext={onAddTerminalContext}
focusRequestId={focusRequestId}
autoFocus={terminalId === resolvedActiveTerminalId}
visible={visible}
resizeEpoch={resizeEpoch}
drawerHeight={drawerHeight}
keybindings={keybindings}
Expand Down Expand Up @@ -1571,6 +1586,7 @@ export default function ThreadTerminalDrawer({
onAddTerminalContext={onAddTerminalContext}
focusRequestId={focusRequestId}
autoFocus
visible={visible}
resizeEpoch={resizeEpoch}
drawerHeight={drawerHeight}
keybindings={keybindings}
Expand Down
63 changes: 54 additions & 9 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,8 @@ export interface GhosttySelectionPosition {
export interface GhosttyTerminalSurfaceOptions {
readonly theme: GhosttyTheme;
readonly font?: GhosttyTerminalFont;
/** Read after font and WASM loading. Hosts can supply a getter for the latest value. */
readonly visible?: boolean;
readonly onData: (data: string) => void;
readonly onResize: (cols: number, rows: number) => void;
readonly onSelectionChange: () => void;
Expand All @@ -555,6 +557,8 @@ export class GhosttyTerminalSurface {
private readonly context: CanvasRenderingContext2D;
private readonly core: GhosttyTerminalCore;
private readonly options: GhosttyTerminalSurfaceOptions;
private visible: boolean;
private hasSize = false;
private metrics: GhosttyCellMetrics;
private fontFamily: string;
private requestedFontFamily: string | undefined;
Expand Down Expand Up @@ -642,6 +646,7 @@ export class GhosttyTerminalSurface {
this.mouseAnyEventTracking = core.isMouseAnyEventTracking();
this.metrics = metrics;
this.options = options;
this.visible = options.visible ?? true;
this.theme = options.theme;
this.fontFamily = fontFamily;
this.requestedFontFamily = options.font?.family;
Expand Down Expand Up @@ -724,10 +729,24 @@ export class GhosttyTerminalSurface {
options,
);
surface.fit();
surface.requestRender();
return surface;
}

/** Pause canvas work without interrupting output parsing or terminal replies. */
setVisible(visible: boolean): void {
if (this.disposed || this.visible === visible) return;
this.visible = visible;
this.cursorOn = true;
this.forceFullRender = true;
this.scrollbarDirty = true;
if (!visible) {
this.cancelRender();
this.setSelectionAutoscroll(0);
return;
}
this.fit();
}

write(data: string): void {
if (this.disposed) return;
this.core.write(data);
Expand Down Expand Up @@ -823,10 +842,16 @@ export class GhosttyTerminalSurface {
};

fit(): boolean {
if (this.disposed) return false;
if (this.disposed || !this.visible) return false;
const width = this.mount.clientWidth;
const height = this.mount.clientHeight;
if (width <= 0 || height <= 0) return false;
if (width <= 0 || height <= 0) {
this.hasSize = false;
this.forceFullRender = true;
this.cancelRender();
return false;
}
this.hasSize = true;
const ratio = window.devicePixelRatio || 1;
const pixelWidth = Math.max(1, Math.round(width * ratio));
const pixelHeight = Math.max(1, Math.round(height * ratio));
Expand Down Expand Up @@ -863,7 +888,7 @@ export class GhosttyTerminalSurface {
// Rendering synchronously keeps the repaint inside the same frame as the
// layout change: ResizeObserver fires before paint, so the browser never
// composites the old backing store stretched into the new element box.
if (shouldRender) this.renderFrame();
if (shouldRender || this.forceFullRender) this.renderFrame();
return true;
}

Expand All @@ -882,6 +907,7 @@ export class GhosttyTerminalSurface {
}

focus(): void {
if (this.disposed || !this.visible) return;
this.input.focus({ preventScroll: true });
}

Expand Down Expand Up @@ -981,8 +1007,7 @@ export class GhosttyTerminalSurface {
// the surface unmounts inside the debounce window.
this.options.onResize(this.cols, this.rows);
}
if (this.frame !== 0) window.cancelAnimationFrame(this.frame);
if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
this.cancelRender();
if (this.compositionSuppressionTimer !== null) {
window.clearTimeout(this.compositionSuppressionTimer);
}
Expand Down Expand Up @@ -1675,19 +1700,39 @@ export class GhosttyTerminalSurface {
}

private requestRender(): void {
if (this.disposed || this.frame !== 0) return;
if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return;
this.frame = window.requestAnimationFrame(() => {
this.frame = 0;
this.renderFrame();
});
}

private cancelRender(): void {
if (this.frame !== 0) {
window.cancelAnimationFrame(this.frame);
this.frame = 0;
}
if (this.cursorTimer !== null) {
window.clearTimeout(this.cursorTimer);
this.cursorTimer = null;
}
}

private renderFrame(): void {
if (this.disposed) return;
if (this.disposed || !this.visible) return;
if (this.frame !== 0) {
window.cancelAnimationFrame(this.frame);
this.frame = 0;
}
// Hidden thread drawers stay mounted so switching back is instant, but a
// display:none canvas has nothing to show. Ghostty keeps parsing; the
// ResizeObserver refits and repaints in full once the mount has a size.
if (this.mount.clientWidth === 0 || this.mount.clientHeight === 0) {
this.hasSize = false;
this.forceFullRender = true;
this.cancelRender();
return;
}
this.snapshot = this.core.snapshot();
// A cursor that is not blinking right now must be drawn, never caught in an
// off phase left behind by a blink that has since been turned off.
Expand Down Expand Up @@ -1753,7 +1798,7 @@ export class GhosttyTerminalSurface {

private blinkEnabled(): boolean {
const snapshot = this.snapshot;
if (!snapshot) return false;
if (!snapshot || !this.visible || !this.hasSize) return false;
return shouldBlinkTerminalCursor({
focused: this.focused,
cursorBlinking: snapshot.cursorBlinking,
Expand Down
Loading