| author | |
| committer | |
| log | 5eab021a5185e492b6b021f83564143d5263e8a6 |
| tree | bfa9fad171e5c383503197d878a80d313418b9a7 |
| parent | 9eb4d71681dc7d002082db2f8b4bacf7614412f4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Stop post-construction terminal snapshots, canvas paint, and cursor timers while a drawer or right panel is hidden. Keep parsing output and answering VT queries, then render current state once on reveal.
Cover delayed WASM initialization, selection behavior, zero-size mounts, and reveal with real-core headless tests. Keep the existing startup background fill.
Continue the zero-size guard from source commit eb5b68103506b1bb5bd81c3b9e27f11e349743c9 in the terminal streaming contribution.
Created with GPT-6 Astra (preview) in Codex.
Co-Authored-By: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>4 files changed, 401 insertions(+), 16 deletions(-)
apps/web/src/components/ChatView.tsx+4| ... | @@ -1154,6 +1154,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -1154,6 +1154,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 1154 | }); | 1154 | }); |
| 1155 | 1155 | ||
| 1156 | interface PersistentThreadTerminalPanelProps { | 1156 | interface PersistentThreadTerminalPanelProps { |
| 1157 | visible: boolean; | ||
| 1157 | threadRef: ScopedThreadRef; | 1158 | threadRef: ScopedThreadRef; |
| 1158 | surface: Extract<RightPanelSurface, { kind: "terminal" }>; | 1159 | surface: Extract<RightPanelSurface, { kind: "terminal" }>; |
| 1159 | launchContext: PersistentTerminalLaunchContext | null; | 1160 | launchContext: PersistentTerminalLaunchContext | null; |
| ... | @@ -1172,6 +1173,7 @@ interface PersistentThreadTerminalPanelProps { | ... | @@ -1172,6 +1173,7 @@ interface PersistentThreadTerminalPanelProps { |
| 1172 | } | 1173 | } |
| 1173 | 1174 | ||
| 1174 | const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ | 1175 | const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ |
| 1176 | visible, | ||
| 1175 | threadRef, | 1177 | threadRef, |
| 1176 | surface, | 1178 | surface, |
| 1177 | launchContext, | 1179 | launchContext, |
| ... | @@ -1287,6 +1289,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane | ... | @@ -1287,6 +1289,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane |
| 1287 | return ( | 1289 | return ( |
| 1288 | <ThreadTerminalDrawer | 1290 | <ThreadTerminalDrawer |
| 1289 | mode="panel" | 1291 | mode="panel" |
| 1292 | visible={visible} | ||
| 1290 | threadRef={threadRef} | 1293 | threadRef={threadRef} |
| 1291 | threadId={threadRef.threadId} | 1294 | threadId={threadRef.threadId} |
| 1292 | cwd={cwd} | 1295 | cwd={cwd} |
| ... | @@ -7549,6 +7552,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7549,6 +7552,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7549 | </Suspense> | 7552 | </Suspense> |
| 7550 | ) : renderedRightPanelSurface?.kind === "terminal" ? ( | 7553 | ) : renderedRightPanelSurface?.kind === "terminal" ? ( |
| 7551 | <PersistentThreadTerminalPanel | 7554 | <PersistentThreadTerminalPanel |
| 7555 | visible={rightPanelOpen} | ||
| 7552 | threadRef={activeThreadRef} | 7556 | threadRef={activeThreadRef} |
| 7553 | surface={renderedRightPanelSurface} | 7557 | surface={renderedRightPanelSurface} |
| 7554 | launchContext={activeTerminalLaunchContext ?? null} | 7558 | launchContext={activeTerminalLaunchContext ?? null} |
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5| ... | @@ -33,6 +33,7 @@ import { | ... | @@ -33,6 +33,7 @@ import { |
| 33 | useCallback, | 33 | useCallback, |
| 34 | useEffect, | 34 | useEffect, |
| 35 | useEffectEvent, | 35 | useEffectEvent, |
| 36 | useLayoutEffect, | ||
| 36 | useMemo, | 37 | useMemo, |
| 37 | useRef, | 38 | useRef, |
| 38 | useState, | 39 | useState, |
| ... | @@ -295,6 +296,7 @@ interface TerminalViewportProps { | ... | @@ -295,6 +296,7 @@ interface TerminalViewportProps { |
| 295 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | 296 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 296 | focusRequestId: number; | 297 | focusRequestId: number; |
| 297 | autoFocus: boolean; | 298 | autoFocus: boolean; |
| 299 | visible: boolean; | ||
| 298 | resizeEpoch: number; | 300 | resizeEpoch: number; |
| 299 | drawerHeight: number; | 301 | drawerHeight: number; |
| 300 | keybindings: ResolvedKeybindingsConfig; | 302 | keybindings: ResolvedKeybindingsConfig; |
| ... | @@ -319,12 +321,14 @@ export function TerminalViewport({ | ... | @@ -319,12 +321,14 @@ export function TerminalViewport({ |
| 319 | onAddTerminalContext, | 321 | onAddTerminalContext, |
| 320 | focusRequestId, | 322 | focusRequestId, |
| 321 | autoFocus, | 323 | autoFocus, |
| 324 | visible, | ||
| 322 | resizeEpoch, | 325 | resizeEpoch, |
| 323 | drawerHeight, | 326 | drawerHeight, |
| 324 | keybindings, | 327 | keybindings, |
| 325 | }: TerminalViewportProps) { | 328 | }: TerminalViewportProps) { |
| 326 | const containerRef = useRef<HTMLDivElement>(null); | 329 | const containerRef = useRef<HTMLDivElement>(null); |
| 327 | const terminalRef = useRef<GhosttyTerminalSurface | null>(null); | 330 | const terminalRef = useRef<GhosttyTerminalSurface | null>(null); |
| 331 | const visibleRef = useRef(visible); | ||
| 328 | const environmentId = threadRef.environmentId; | 332 | const environmentId = threadRef.environmentId; |
| 329 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | 333 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 330 | const openInPreferredEditor = useOpenInPreferredEditor( | 334 | const openInPreferredEditor = useOpenInPreferredEditor( |
| ... | @@ -434,6 +438,11 @@ export function TerminalViewport({ | ... | @@ -434,6 +438,11 @@ export function TerminalViewport({ |
| 434 | keybindingsRef.current = keybindings; | 438 | keybindingsRef.current = keybindings; |
| 435 | }, [keybindings]); | 439 | }, [keybindings]); |
| 436 | 440 | ||
| 441 | useLayoutEffect(() => { | ||
| 442 | visibleRef.current = visible; | ||
| 443 | terminalRef.current?.setVisible(visible); | ||
| 444 | }, [visible]); | ||
| 445 | |||
| 437 | useEffect(() => { | 446 | useEffect(() => { |
| 438 | const current = terminalFontRef.current; | 447 | const current = terminalFontRef.current; |
| 439 | if (current.family === terminalFontFamily && current.size === terminalFontSize) return; | 448 | if (current.family === terminalFontFamily && current.size === terminalFontSize) return; |
| ... | @@ -457,6 +466,9 @@ export function TerminalViewport({ | ... | @@ -457,6 +466,9 @@ export function TerminalViewport({ |
| 457 | const terminalOptions: GhosttyTerminalSurfaceOptions = { | 466 | const terminalOptions: GhosttyTerminalSurfaceOptions = { |
| 458 | theme: terminalThemeFromApp(mount), | 467 | theme: terminalThemeFromApp(mount), |
| 459 | font: terminalFontOptions(setupFont.family, setupFont.size), | 468 | font: terminalFontOptions(setupFont.family, setupFont.size), |
| 469 | get visible() { | ||
| 470 | return visibleRef.current; | ||
| 471 | }, | ||
| 460 | onData: (data) => handleData(data), | 472 | onData: (data) => handleData(data), |
| 461 | onResize: (cols, rows) => void resizeTerminal(cols, rows), | 473 | onResize: (cols, rows) => void resizeTerminal(cols, rows), |
| 462 | onSelectionChange: () => handleSelectionChange(), | 474 | onSelectionChange: () => handleSelectionChange(), |
| ... | @@ -474,6 +486,7 @@ export function TerminalViewport({ | ... | @@ -474,6 +486,7 @@ export function TerminalViewport({ |
| 474 | terminal.dispose(); | 486 | terminal.dispose(); |
| 475 | return null; | 487 | return null; |
| 476 | } | 488 | } |
| 489 | terminal.setVisible(visibleRef.current); | ||
| 477 | // The theme observer is not installed yet, so re-read the theme in case | 490 | // The theme observer is not installed yet, so re-read the theme in case |
| 478 | // the app toggled light/dark while the WASM surface was loading. | 491 | // the app toggled light/dark while the WASM surface was loading. |
| 479 | terminal.setTheme(terminalThemeFromApp(mount)); | 492 | terminal.setTheme(terminalThemeFromApp(mount)); |
| ... | @@ -503,7 +516,7 @@ export function TerminalViewport({ | ... | @@ -503,7 +516,7 @@ export function TerminalViewport({ |
| 503 | // never started, so only "exited" triggers the message — as with xterm.) | 516 | // never started, so only "exited" triggers the message — as with xterm.) |
| 504 | synchronizedStatusRef.current = "closed"; | 517 | synchronizedStatusRef.current = "closed"; |
| 505 | synchronizeTerminalStatus(terminal, latestSession.status); | 518 | synchronizeTerminalStatus(terminal, latestSession.status); |
| 506 | if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); | 519 | if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus()); |
| 507 | 520 | ||
| 508 | const dismissSelectionAction = (supersede = false) => { | 521 | const dismissSelectionAction = (supersede = false) => { |
| 509 | const ownsMenu = | 522 | const ownsMenu = |
| ... | @@ -891,7 +904,7 @@ export function TerminalViewport({ | ... | @@ -891,7 +904,7 @@ export function TerminalViewport({ |
| 891 | writeSystemMessage(terminal, current.error); | 904 | writeSystemMessage(terminal, current.error); |
| 892 | } | 905 | } |
| 893 | 906 | ||
| 894 | if (previous.version === 0 && autoFocus) { | 907 | if (previous.version === 0 && autoFocus && visibleRef.current) { |
| 895 | window.requestAnimationFrame(() => { | 908 | window.requestAnimationFrame(() => { |
| 896 | terminal.focus(); | 909 | terminal.focus(); |
| 897 | }); | 910 | }); |
| ... | @@ -900,7 +913,7 @@ export function TerminalViewport({ | ... | @@ -900,7 +913,7 @@ export function TerminalViewport({ |
| 900 | }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); | 913 | }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); |
| 901 | 914 | ||
| 902 | useEffect(() => { | 915 | useEffect(() => { |
| 903 | if (!autoFocus) return; | 916 | if (!autoFocus || !visible) return; |
| 904 | const terminal = terminalRef.current; | 917 | const terminal = terminalRef.current; |
| 905 | if (!terminal) return; | 918 | if (!terminal) return; |
| 906 | const frame = window.requestAnimationFrame(() => { | 919 | const frame = window.requestAnimationFrame(() => { |
| ... | @@ -909,15 +922,16 @@ export function TerminalViewport({ | ... | @@ -909,15 +922,16 @@ export function TerminalViewport({ |
| 909 | return () => { | 922 | return () => { |
| 910 | window.cancelAnimationFrame(frame); | 923 | window.cancelAnimationFrame(frame); |
| 911 | }; | 924 | }; |
| 912 | }, [autoFocus, focusRequestId]); | 925 | }, [autoFocus, focusRequestId, visible]); |
| 913 | 926 | ||
| 914 | useEffect(() => { | 927 | useEffect(() => { |
| 915 | const terminal = terminalRef.current; | 928 | const terminal = terminalRef.current; |
| 916 | if (!terminal) return; | 929 | if (!terminal || !visibleRef.current) return; |
| 917 | const wasAtBottom = terminal.isAtBottom(); | 930 | const wasAtBottom = terminal.isAtBottom(); |
| 918 | // The surface reports grid changes through onResize, which is the single | 931 | // The surface reports grid changes through onResize, which is the single |
| 919 | // channel for PTY resize RPCs; fitting here only refreshes the layout. | 932 | // channel for PTY resize RPCs; fitting here only refreshes the layout. |
| 920 | const frame = window.requestAnimationFrame(() => { | 933 | const frame = window.requestAnimationFrame(() => { |
| 934 | if (!visibleRef.current) return; | ||
| 921 | terminal.fit(); | 935 | terminal.fit(); |
| 922 | if (wasAtBottom) { | 936 | if (wasAtBottom) { |
| 923 | terminal.scrollToBottom(); | 937 | terminal.scrollToBottom(); |
| ... | @@ -1493,6 +1507,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1493,6 +1507,7 @@ export default function ThreadTerminalDrawer({ |
| 1493 | onAddTerminalContext={onAddTerminalContext} | 1507 | onAddTerminalContext={onAddTerminalContext} |
| 1494 | focusRequestId={focusRequestId} | 1508 | focusRequestId={focusRequestId} |
| 1495 | autoFocus={terminalId === resolvedActiveTerminalId} | 1509 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 1510 | visible={visible} | ||
| 1496 | resizeEpoch={resizeEpoch} | 1511 | resizeEpoch={resizeEpoch} |
| 1497 | drawerHeight={drawerHeight} | 1512 | drawerHeight={drawerHeight} |
| 1498 | keybindings={keybindings} | 1513 | keybindings={keybindings} |
| ... | @@ -1522,6 +1537,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1522,6 +1537,7 @@ export default function ThreadTerminalDrawer({ |
| 1522 | onAddTerminalContext={onAddTerminalContext} | 1537 | onAddTerminalContext={onAddTerminalContext} |
| 1523 | focusRequestId={focusRequestId} | 1538 | focusRequestId={focusRequestId} |
| 1524 | autoFocus | 1539 | autoFocus |
| 1540 | visible={visible} | ||
| 1525 | resizeEpoch={resizeEpoch} | 1541 | resizeEpoch={resizeEpoch} |
| 1526 | drawerHeight={drawerHeight} | 1542 | drawerHeight={drawerHeight} |
| 1527 | keybindings={keybindings} | 1543 | keybindings={keybindings} |
apps/web/src/terminal/ghostty/surface.test.ts+322-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import type { GhosttyCell, GhosttyRow } from "./core"; | 3 | import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; |
| 4 | import { | 4 | import { |
| 5 | DEFAULT_TERMINAL_FONT_FAMILY, | 5 | DEFAULT_TERMINAL_FONT_FAMILY, |
| 6 | DEFAULT_TERMINAL_FONT_SIZE, | 6 | DEFAULT_TERMINAL_FONT_SIZE, |
| ... | @@ -31,8 +31,328 @@ import { | ... | @@ -31,8 +31,328 @@ import { |
| 31 | terminalFontSize, | 31 | terminalFontSize, |
| 32 | terminalWheelArrowData, | 32 | terminalWheelArrowData, |
| 33 | terminalWheelDeltaRows, | 33 | terminalWheelDeltaRows, |
| 34 | GhosttyTerminalSurface, | ||
| 35 | type GhosttyTerminalSurfaceOptions, | ||
| 34 | } from "./surface"; | 36 | } from "./surface"; |
| 35 | 37 | ||
| 38 | vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ | ||
| 39 | default: (await import("./vendor/ghostty-vt.wasm?inline")).default, | ||
| 40 | })); | ||
| 41 | vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ | ||
| 42 | default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, | ||
| 43 | })); | ||
| 44 | |||
| 45 | describe("GhosttyTerminalSurface visibility", () => { | ||
| 46 | const surfaces = new Set<GhosttyTerminalSurface>(); | ||
| 47 | |||
| 48 | // Keep the real surface, renderer, and WASM core. Only browser layout and | ||
| 49 | // scheduling are replaced so tests can count work while the terminal is hidden. | ||
| 50 | function createHarness() { | ||
| 51 | vi.useFakeTimers(); | ||
| 52 | const frames = new Map<number, FrameRequestCallback>(); | ||
| 53 | const resizeCallbacks = new Set<() => void>(); | ||
| 54 | const paint = vi.fn((_operation: string, _args: ReadonlyArray<unknown>) => {}); | ||
| 55 | let frameId = 0; | ||
| 56 | const requestFrame = vi.fn((callback: FrameRequestCallback) => { | ||
| 57 | frames.set(++frameId, callback); | ||
| 58 | return frameId; | ||
| 59 | }); | ||
| 60 | |||
| 61 | class TerminalTestElement extends EventTarget { | ||
| 62 | style: Record<string, string> = {}; | ||
| 63 | parentElement: TerminalTestElement | null = null; | ||
| 64 | clientWidth = 168; | ||
| 65 | clientHeight = 104; | ||
| 66 | width = 300; | ||
| 67 | height = 150; | ||
| 68 | value = ""; | ||
| 69 | private readonly captures = new Set<number>(); | ||
| 70 | |||
| 71 | setAttribute() {} | ||
| 72 | append(...children: TerminalTestElement[]) { | ||
| 73 | for (const child of children) child.parentElement = this; | ||
| 74 | } | ||
| 75 | replaceChildren(...children: TerminalTestElement[]) { | ||
| 76 | this.append(...children); | ||
| 77 | } | ||
| 78 | remove() { | ||
| 79 | this.parentElement = null; | ||
| 80 | } | ||
| 81 | getContext() { | ||
| 82 | return context; | ||
| 83 | } | ||
| 84 | focus() { | ||
| 85 | this.dispatchEvent(new Event("focus")); | ||
| 86 | } | ||
| 87 | setPointerCapture(pointerId: number) { | ||
| 88 | this.captures.add(pointerId); | ||
| 89 | } | ||
| 90 | hasPointerCapture(pointerId: number) { | ||
| 91 | return this.captures.has(pointerId); | ||
| 92 | } | ||
| 93 | releasePointerCapture(pointerId: number) { | ||
| 94 | this.captures.delete(pointerId); | ||
| 95 | } | ||
| 96 | getBoundingClientRect() { | ||
| 97 | return { left: 0, top: 0, right: 168, bottom: 104, width: 168, height: 104 }; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | const canvas = new TerminalTestElement(); | ||
| 102 | const mount = new TerminalTestElement(); | ||
| 103 | const context = { | ||
| 104 | canvas, | ||
| 105 | beginPath() {}, | ||
| 106 | clip() {}, | ||
| 107 | rect() {}, | ||
| 108 | resetTransform() {}, | ||
| 109 | restore() {}, | ||
| 110 | save() {}, | ||
| 111 | setTransform() {}, | ||
| 112 | fillRect: (...args: number[]) => paint("fillRect", args), | ||
| 113 | strokeRect: (...args: number[]) => paint("strokeRect", args), | ||
| 114 | fillText: (...args: [string, number, number, number?]) => paint("fillText", args), | ||
| 115 | measureText: (text: string) => ({ | ||
| 116 | width: text.length * 8, | ||
| 117 | actualBoundingBoxAscent: 9, | ||
| 118 | actualBoundingBoxDescent: 3, | ||
| 119 | }), | ||
| 120 | }; | ||
| 121 | vi.stubGlobal("document", { | ||
| 122 | createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()), | ||
| 123 | fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }), | ||
| 124 | }); | ||
| 125 | vi.stubGlobal( | ||
| 126 | "window", | ||
| 127 | Object.assign(new EventTarget(), { | ||
| 128 | devicePixelRatio: 1, | ||
| 129 | requestAnimationFrame: requestFrame, | ||
| 130 | cancelAnimationFrame: (id: number) => frames.delete(id), | ||
| 131 | setTimeout, | ||
| 132 | clearTimeout, | ||
| 133 | setInterval, | ||
| 134 | clearInterval, | ||
| 135 | matchMedia: () => Object.assign(new EventTarget(), { matches: false }), | ||
| 136 | }), | ||
| 137 | ); | ||
| 138 | vi.stubGlobal( | ||
| 139 | "ResizeObserver", | ||
| 140 | class { | ||
| 141 | constructor(private readonly callback: () => void) { | ||
| 142 | resizeCallbacks.add(callback); | ||
| 143 | } | ||
| 144 | observe() {} | ||
| 145 | disconnect() { | ||
| 146 | resizeCallbacks.delete(this.callback); | ||
| 147 | } | ||
| 148 | }, | ||
| 149 | ); | ||
| 150 | const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); | ||
| 151 | const onData = vi.fn<(data: string) => void>(); | ||
| 152 | |||
| 153 | return { | ||
| 154 | mount, | ||
| 155 | frames, | ||
| 156 | paint, | ||
| 157 | requestFrame, | ||
| 158 | snapshot, | ||
| 159 | onData, | ||
| 160 | get renderedSnapshot() { | ||
| 161 | const result = snapshot.mock.results.at(-1); | ||
| 162 | if (result?.type !== "return") throw new Error("No terminal snapshot was rendered"); | ||
| 163 | return result.value; | ||
| 164 | }, | ||
| 165 | flushFrame() { | ||
| 166 | const queued = [...frames.values()]; | ||
| 167 | frames.clear(); | ||
| 168 | for (const callback of queued) callback(0); | ||
| 169 | }, | ||
| 170 | resize() { | ||
| 171 | for (const callback of resizeCallbacks) callback(); | ||
| 172 | }, | ||
| 173 | pointer(type: string, clientX: number, buttons: number) { | ||
| 174 | canvas.dispatchEvent( | ||
| 175 | Object.assign(new Event(type, { cancelable: true }), { | ||
| 176 | clientX, | ||
| 177 | clientY: 5, | ||
| 178 | pointerId: 1, | ||
| 179 | button: 0, | ||
| 180 | buttons, | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | }, | ||
| 184 | async create(options: Partial<GhosttyTerminalSurfaceOptions> = {}) { | ||
| 185 | const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { | ||
| 186 | theme: { | ||
| 187 | foreground: { r: 255, g: 255, b: 255 }, | ||
| 188 | background: { r: 0, g: 0, b: 0 }, | ||
| 189 | cursor: { r: 255, g: 255, b: 255 }, | ||
| 190 | }, | ||
| 191 | onData, | ||
| 192 | onResize() {}, | ||
| 193 | onSelectionChange() {}, | ||
| 194 | beforeKey: () => false, | ||
| 195 | onLinkActivate() {}, | ||
| 196 | ...options, | ||
| 197 | get visible() { | ||
| 198 | return options.visible ?? true; | ||
| 199 | }, | ||
| 200 | }); | ||
| 201 | surfaces.add(surface); | ||
| 202 | return surface; | ||
| 203 | }, | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | |||
| 207 | afterEach(() => { | ||
| 208 | for (const surface of surfaces) surface.dispose(); | ||
| 209 | surfaces.clear(); | ||
| 210 | vi.useRealTimers(); | ||
| 211 | vi.unstubAllGlobals(); | ||
| 212 | vi.restoreAllMocks(); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { | ||
| 216 | const harness = createHarness(); | ||
| 217 | const surface = await harness.create(); | ||
| 218 | surface.focus(); | ||
| 219 | surface.write("ready\x1b[1 q"); | ||
| 220 | harness.flushFrame(); | ||
| 221 | vi.advanceTimersByTime(500); | ||
| 222 | harness.flushFrame(); | ||
| 223 | surface.write("queued"); | ||
| 224 | expect(harness.frames.size).toBe(1); | ||
| 225 | surface.setVisible(false); | ||
| 226 | expect(harness.frames.size).toBe(0); | ||
| 227 | expect(vi.getTimerCount()).toBe(0); | ||
| 228 | harness.snapshot.mockClear(); | ||
| 229 | harness.paint.mockClear(); | ||
| 230 | harness.requestFrame.mockClear(); | ||
| 231 | |||
| 232 | surface.write("\x1b[2J\x1b[H\x1b[3"); | ||
| 233 | surface.write("1mhidden"); | ||
| 234 | surface.write("界🙂\x1b[0m\x1b[5n\x1b[6n"); | ||
| 235 | surface.fit(); | ||
| 236 | vi.advanceTimersByTime(2_000); | ||
| 237 | harness.flushFrame(); | ||
| 238 | |||
| 239 | expect(harness.onData.mock.calls).toEqual([["\x1b[0n"], ["\x1b[1;11R"]]); | ||
| 240 | expect(harness.snapshot).not.toHaveBeenCalled(); | ||
| 241 | expect(harness.paint).not.toHaveBeenCalled(); | ||
| 242 | expect(harness.requestFrame).not.toHaveBeenCalled(); | ||
| 243 | |||
| 244 | surface.setVisible(true); | ||
| 245 | expect(harness.snapshot).toHaveBeenCalledTimes(1); | ||
| 246 | expect(harness.renderedSnapshot).toMatchObject({ cursorX: 10, cursorY: 0 }); | ||
| 247 | expect(harness.renderedSnapshot.rowData[0]?.text).toContain("hidden"); | ||
| 248 | expect(harness.paint.mock.calls).toContainEqual(["fillRect", [84, 4, 8, 16]]); | ||
| 249 | expect(harness.frames.size).toBe(0); | ||
| 250 | }); | ||
| 251 | |||
| 252 | it("keeps the selection on reveal and applies a hidden selection clear", async () => { | ||
| 253 | const harness = createHarness(); | ||
| 254 | const surface = await harness.create(); | ||
| 255 | surface.write("hello world"); | ||
| 256 | harness.flushFrame(); | ||
| 257 | harness.pointer("pointerdown", 5, 1); | ||
| 258 | harness.pointer("pointermove", 37, 1); | ||
| 259 | harness.pointer("pointerup", 37, 0); | ||
| 260 | harness.flushFrame(); | ||
| 261 | expect(surface.getSelection()).toBe("hello"); | ||
| 262 | const position = surface.getSelectionPosition(); | ||
| 263 | |||
| 264 | surface.setVisible(false); | ||
| 265 | harness.snapshot.mockClear(); | ||
| 266 | surface.setVisible(true); | ||
| 267 | expect(harness.snapshot).toHaveBeenCalledTimes(1); | ||
| 268 | expect(surface.getSelectionPosition()).toEqual(position); | ||
| 269 | expect( | ||
| 270 | harness.renderedSnapshot.rowData[0]?.cells.slice(0, 5).every((cell) => cell.selected), | ||
| 271 | ).toBe(true); | ||
| 272 | |||
| 273 | surface.setVisible(false); | ||
| 274 | harness.snapshot.mockClear(); | ||
| 275 | surface.clearSelection(); | ||
| 276 | surface.write("!"); | ||
| 277 | expect(harness.snapshot).not.toHaveBeenCalled(); | ||
| 278 | surface.setVisible(true); | ||
| 279 | expect(harness.snapshot).toHaveBeenCalledTimes(1); | ||
| 280 | expect(surface.getSelection()).toBe(""); | ||
| 281 | expect(surface.getSelectionPosition()).toBeNull(); | ||
| 282 | expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); | ||
| 283 | }); | ||
| 284 | |||
| 285 | it("stops zero-size mounts and repaints when the same size returns", async () => { | ||
| 286 | const harness = createHarness(); | ||
| 287 | const surface = await harness.create(); | ||
| 288 | surface.focus(); | ||
| 289 | surface.write("visible"); | ||
| 290 | harness.flushFrame(); | ||
| 291 | harness.snapshot.mockClear(); | ||
| 292 | harness.paint.mockClear(); | ||
| 293 | harness.mount.clientWidth = 0; | ||
| 294 | surface.write("!"); | ||
| 295 | harness.flushFrame(); | ||
| 296 | harness.requestFrame.mockClear(); | ||
| 297 | for (let index = 0; index < 8; index += 1) surface.write("x"); | ||
| 298 | vi.advanceTimersByTime(2_000); | ||
| 299 | |||
| 300 | expect(harness.snapshot).not.toHaveBeenCalled(); | ||
| 301 | expect(harness.paint).not.toHaveBeenCalled(); | ||
| 302 | expect(harness.requestFrame).not.toHaveBeenCalled(); | ||
| 303 | harness.mount.clientWidth = 168; | ||
| 304 | harness.resize(); | ||
| 305 | expect(harness.snapshot).toHaveBeenCalledTimes(1); | ||
| 306 | expect(harness.renderedSnapshot.rowData[0]?.text).toContain("visible!xxxxxxxx"); | ||
| 307 | expect(harness.paint).toHaveBeenCalled(); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it.each([false, true])( | ||
| 311 | "uses visibility %s if it changes while WASM initializes", | ||
| 312 | async (visible) => { | ||
| 313 | const harness = createHarness(); | ||
| 314 | let markCreated!: () => void; | ||
| 315 | const created = new Promise<void>((resolve) => { | ||
| 316 | markCreated = resolve; | ||
| 317 | }); | ||
| 318 | let finishLoading!: () => void; | ||
| 319 | const release = new Promise<void>((resolve) => { | ||
| 320 | finishLoading = resolve; | ||
| 321 | }); | ||
| 322 | const create = GhosttyTerminalCore.create; | ||
| 323 | vi.spyOn(GhosttyTerminalCore, "create").mockImplementation(async (...args) => { | ||
| 324 | const core = await create(...args); | ||
| 325 | markCreated(); | ||
| 326 | await release; | ||
| 327 | return core; | ||
| 328 | }); | ||
| 329 | let currentVisible = !visible; | ||
| 330 | const pending = harness.create({ | ||
| 331 | get visible() { | ||
| 332 | return currentVisible; | ||
| 333 | }, | ||
| 334 | }); | ||
| 335 | await created; | ||
| 336 | currentVisible = visible; | ||
| 337 | harness.paint.mockClear(); | ||
| 338 | finishLoading(); | ||
| 339 | const surface = await pending; | ||
| 340 | |||
| 341 | expect(harness.snapshot).toHaveBeenCalledTimes(visible ? 1 : 0); | ||
| 342 | if (!visible) expect(harness.paint).not.toHaveBeenCalled(); | ||
| 343 | surface.write("ready\x1b[5n"); | ||
| 344 | expect(harness.onData).toHaveBeenCalledWith("\x1b[0n"); | ||
| 345 | if (!visible) { | ||
| 346 | expect(harness.frames.size).toBe(0); | ||
| 347 | surface.setVisible(true); | ||
| 348 | } else { | ||
| 349 | harness.flushFrame(); | ||
| 350 | } | ||
| 351 | expect(harness.renderedSnapshot.rowData[0]?.text).toContain("ready"); | ||
| 352 | }, | ||
| 353 | ); | ||
| 354 | }); | ||
| 355 | |||
| 36 | const cell = (text: string): GhosttyCell => ({ | 356 | const cell = (text: string): GhosttyCell => ({ |
| 37 | text, | 357 | text, |
| 38 | wide: 0, | 358 | wide: 0, |
apps/web/src/terminal/ghostty/surface.ts+54-9| ... | @@ -532,6 +532,8 @@ export interface GhosttySelectionPosition { | ... | @@ -532,6 +532,8 @@ export interface GhosttySelectionPosition { |
| 532 | export interface GhosttyTerminalSurfaceOptions { | 532 | export interface GhosttyTerminalSurfaceOptions { |
| 533 | readonly theme: GhosttyTheme; | 533 | readonly theme: GhosttyTheme; |
| 534 | readonly font?: GhosttyTerminalFont; | 534 | readonly font?: GhosttyTerminalFont; |
| 535 | /** Read after font and WASM loading. Hosts can supply a getter for the latest value. */ | ||
| 536 | readonly visible?: boolean; | ||
| 535 | readonly onData: (data: string) => void; | 537 | readonly onData: (data: string) => void; |
| 536 | readonly onResize: (cols: number, rows: number) => void; | 538 | readonly onResize: (cols: number, rows: number) => void; |
| 537 | readonly onSelectionChange: () => void; | 539 | readonly onSelectionChange: () => void; |
| ... | @@ -556,6 +558,8 @@ export class GhosttyTerminalSurface { | ... | @@ -556,6 +558,8 @@ export class GhosttyTerminalSurface { |
| 556 | private readonly context: CanvasRenderingContext2D; | 558 | private readonly context: CanvasRenderingContext2D; |
| 557 | private readonly core: GhosttyTerminalCore; | 559 | private readonly core: GhosttyTerminalCore; |
| 558 | private readonly options: GhosttyTerminalSurfaceOptions; | 560 | private readonly options: GhosttyTerminalSurfaceOptions; |
| 561 | private visible: boolean; | ||
| 562 | private hasSize = false; | ||
| 559 | private metrics: GhosttyCellMetrics; | 563 | private metrics: GhosttyCellMetrics; |
| 560 | private fontFamily: string; | 564 | private fontFamily: string; |
| 561 | private requestedFontFamily: string | undefined; | 565 | private requestedFontFamily: string | undefined; |
| ... | @@ -643,6 +647,7 @@ export class GhosttyTerminalSurface { | ... | @@ -643,6 +647,7 @@ export class GhosttyTerminalSurface { |
| 643 | this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); | 647 | this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); |
| 644 | this.metrics = metrics; | 648 | this.metrics = metrics; |
| 645 | this.options = options; | 649 | this.options = options; |
| 650 | this.visible = options.visible ?? true; | ||
| 646 | this.theme = options.theme; | 651 | this.theme = options.theme; |
| 647 | this.fontFamily = fontFamily; | 652 | this.fontFamily = fontFamily; |
| 648 | this.requestedFontFamily = options.font?.family; | 653 | this.requestedFontFamily = options.font?.family; |
| ... | @@ -725,10 +730,24 @@ export class GhosttyTerminalSurface { | ... | @@ -725,10 +730,24 @@ export class GhosttyTerminalSurface { |
| 725 | options, | 730 | options, |
| 726 | ); | 731 | ); |
| 727 | surface.fit(); | 732 | surface.fit(); |
| 728 | surface.requestRender(); | ||
| 729 | return surface; | 733 | return surface; |
| 730 | } | 734 | } |
| 731 | 735 | ||
| 736 | /** Pause canvas work without interrupting output parsing or terminal replies. */ | ||
| 737 | setVisible(visible: boolean): void { | ||
| 738 | if (this.disposed || this.visible === visible) return; | ||
| 739 | this.visible = visible; | ||
| 740 | this.cursorOn = true; | ||
| 741 | this.forceFullRender = true; | ||
| 742 | this.scrollbarDirty = true; | ||
| 743 | if (!visible) { | ||
| 744 | this.cancelRender(); | ||
| 745 | this.setSelectionAutoscroll(0); | ||
| 746 | return; | ||
| 747 | } | ||
| 748 | this.fit(); | ||
| 749 | } | ||
| 750 | |||
| 732 | write(data: string): void { | 751 | write(data: string): void { |
| 733 | if (this.disposed) return; | 752 | if (this.disposed) return; |
| 734 | this.core.write(data); | 753 | this.core.write(data); |
| ... | @@ -824,10 +843,16 @@ export class GhosttyTerminalSurface { | ... | @@ -824,10 +843,16 @@ export class GhosttyTerminalSurface { |
| 824 | }; | 843 | }; |
| 825 | 844 | ||
| 826 | fit(): boolean { | 845 | fit(): boolean { |
| 827 | if (this.disposed) return false; | 846 | if (this.disposed || !this.visible) return false; |
| 828 | const width = this.mount.clientWidth; | 847 | const width = this.mount.clientWidth; |
| 829 | const height = this.mount.clientHeight; | 848 | const height = this.mount.clientHeight; |
| 830 | if (width <= 0 || height <= 0) return false; | 849 | if (width <= 0 || height <= 0) { |
| 850 | this.hasSize = false; | ||
| 851 | this.forceFullRender = true; | ||
| 852 | this.cancelRender(); | ||
| 853 | return false; | ||
| 854 | } | ||
| 855 | this.hasSize = true; | ||
| 831 | const ratio = window.devicePixelRatio || 1; | 856 | const ratio = window.devicePixelRatio || 1; |
| 832 | const pixelWidth = Math.max(1, Math.round(width * ratio)); | 857 | const pixelWidth = Math.max(1, Math.round(width * ratio)); |
| 833 | const pixelHeight = Math.max(1, Math.round(height * ratio)); | 858 | const pixelHeight = Math.max(1, Math.round(height * ratio)); |
| ... | @@ -864,7 +889,7 @@ export class GhosttyTerminalSurface { | ... | @@ -864,7 +889,7 @@ export class GhosttyTerminalSurface { |
| 864 | // Rendering synchronously keeps the repaint inside the same frame as the | 889 | // Rendering synchronously keeps the repaint inside the same frame as the |
| 865 | // layout change: ResizeObserver fires before paint, so the browser never | 890 | // layout change: ResizeObserver fires before paint, so the browser never |
| 866 | // composites the old backing store stretched into the new element box. | 891 | // composites the old backing store stretched into the new element box. |
| 867 | if (shouldRender) this.renderFrame(); | 892 | if (shouldRender || this.forceFullRender) this.renderFrame(); |
| 868 | return true; | 893 | return true; |
| 869 | } | 894 | } |
| 870 | 895 | ||
| ... | @@ -883,6 +908,7 @@ export class GhosttyTerminalSurface { | ... | @@ -883,6 +908,7 @@ export class GhosttyTerminalSurface { |
| 883 | } | 908 | } |
| 884 | 909 | ||
| 885 | focus(): void { | 910 | focus(): void { |
| 911 | if (this.disposed || !this.visible) return; | ||
| 886 | this.input.focus({ preventScroll: true }); | 912 | this.input.focus({ preventScroll: true }); |
| 887 | } | 913 | } |
| 888 | 914 | ||
| ... | @@ -982,8 +1008,7 @@ export class GhosttyTerminalSurface { | ... | @@ -982,8 +1008,7 @@ export class GhosttyTerminalSurface { |
| 982 | // the surface unmounts inside the debounce window. | 1008 | // the surface unmounts inside the debounce window. |
| 983 | this.options.onResize(this.cols, this.rows); | 1009 | this.options.onResize(this.cols, this.rows); |
| 984 | } | 1010 | } |
| 985 | if (this.frame !== 0) window.cancelAnimationFrame(this.frame); | 1011 | this.cancelRender(); |
| 986 | if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); | ||
| 987 | if (this.compositionSuppressionTimer !== null) { | 1012 | if (this.compositionSuppressionTimer !== null) { |
| 988 | window.clearTimeout(this.compositionSuppressionTimer); | 1013 | window.clearTimeout(this.compositionSuppressionTimer); |
| 989 | } | 1014 | } |
| ... | @@ -1676,19 +1701,39 @@ export class GhosttyTerminalSurface { | ... | @@ -1676,19 +1701,39 @@ export class GhosttyTerminalSurface { |
| 1676 | } | 1701 | } |
| 1677 | 1702 | ||
| 1678 | private requestRender(): void { | 1703 | private requestRender(): void { |
| 1679 | if (this.disposed || this.frame !== 0) return; | 1704 | if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; |
| 1680 | this.frame = window.requestAnimationFrame(() => { | 1705 | this.frame = window.requestAnimationFrame(() => { |
| 1681 | this.frame = 0; | 1706 | this.frame = 0; |
| 1682 | this.renderFrame(); | 1707 | this.renderFrame(); |
| 1683 | }); | 1708 | }); |
| 1684 | } | 1709 | } |
| 1685 | 1710 | ||
| 1711 | private cancelRender(): void { | ||
| 1712 | if (this.frame !== 0) { | ||
| 1713 | window.cancelAnimationFrame(this.frame); | ||
| 1714 | this.frame = 0; | ||
| 1715 | } | ||
| 1716 | if (this.cursorTimer !== null) { | ||
| 1717 | window.clearTimeout(this.cursorTimer); | ||
| 1718 | this.cursorTimer = null; | ||
| 1719 | } | ||
| 1720 | } | ||
| 1721 | |||
| 1686 | private renderFrame(): void { | 1722 | private renderFrame(): void { |
| 1687 | if (this.disposed) return; | 1723 | if (this.disposed || !this.visible) return; |
| 1688 | if (this.frame !== 0) { | 1724 | if (this.frame !== 0) { |
| 1689 | window.cancelAnimationFrame(this.frame); | 1725 | window.cancelAnimationFrame(this.frame); |
| 1690 | this.frame = 0; | 1726 | this.frame = 0; |
| 1691 | } | 1727 | } |
| 1728 | // Hidden thread drawers stay mounted so switching back is instant, but a | ||
| 1729 | // display:none canvas has nothing to show. Ghostty keeps parsing; the | ||
| 1730 | // ResizeObserver refits and repaints in full once the mount has a size. | ||
| 1731 | if (this.mount.clientWidth === 0 || this.mount.clientHeight === 0) { | ||
| 1732 | this.hasSize = false; | ||
| 1733 | this.forceFullRender = true; | ||
| 1734 | this.cancelRender(); | ||
| 1735 | return; | ||
| 1736 | } | ||
| 1692 | this.snapshot = this.core.snapshot(); | 1737 | this.snapshot = this.core.snapshot(); |
| 1693 | // A cursor that is not blinking right now must be drawn, never caught in an | 1738 | // A cursor that is not blinking right now must be drawn, never caught in an |
| 1694 | // off phase left behind by a blink that has since been turned off. | 1739 | // off phase left behind by a blink that has since been turned off. |
| ... | @@ -1754,7 +1799,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1754,7 +1799,7 @@ export class GhosttyTerminalSurface { |
| 1754 | 1799 | ||
| 1755 | private blinkEnabled(): boolean { | 1800 | private blinkEnabled(): boolean { |
| 1756 | const snapshot = this.snapshot; | 1801 | const snapshot = this.snapshot; |
| 1757 | if (!snapshot) return false; | 1802 | if (!snapshot || !this.visible || !this.hasSize) return false; |
| 1758 | return shouldBlinkTerminalCursor({ | 1803 | return shouldBlinkTerminalCursor({ |
| 1759 | focused: this.focused, | 1804 | focused: this.focused, |
| 1760 | cursorBlinking: snapshot.cursorBlinking, | 1805 | cursorBlinking: snapshot.cursorBlinking, |