diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 64a377b7c39f..9d4902332320 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -27,6 +27,7 @@ import { useCallback, useEffect, useEffectEvent, + useLayoutEffect, useMemo, useRef, useState, @@ -324,6 +325,7 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; + visible: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; @@ -348,12 +350,14 @@ export function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, + visible, resizeEpoch, drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); + const visibleRef = useRef(visible); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -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; @@ -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(), @@ -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)); @@ -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; @@ -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(); }); @@ -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(() => { @@ -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(); @@ -1542,6 +1556,7 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} + visible={visible} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} @@ -1571,6 +1586,7 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus + visible={visible} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index d4e503d19f00..ee8c8025dafd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -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; @@ -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; @@ -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; @@ -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); @@ -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)); @@ -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; } @@ -882,6 +907,7 @@ export class GhosttyTerminalSurface { } focus(): void { + if (this.disposed || !this.visible) return; this.input.focus({ preventScroll: true }); } @@ -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); } @@ -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. @@ -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,