| 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 | 1154 | }); |
| 1155 | 1155 | |
| 1156 | 1156 | interface PersistentThreadTerminalPanelProps { |
| 1157 | visible: boolean; | |
| 1157 | 1158 | threadRef: ScopedThreadRef; |
| 1158 | 1159 | surface: Extract<RightPanelSurface, { kind: "terminal" }>; |
| 1159 | 1160 | launchContext: PersistentTerminalLaunchContext | null; |
| ... | ... | @@ -1172,6 +1173,7 @@ interface PersistentThreadTerminalPanelProps { |
| 1172 | 1173 | } |
| 1173 | 1174 | |
| 1174 | 1175 | const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ |
| 1176 | visible, | |
| 1175 | 1177 | threadRef, |
| 1176 | 1178 | surface, |
| 1177 | 1179 | launchContext, |
| ... | ... | @@ -1287,6 +1289,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane |
| 1287 | 1289 | return ( |
| 1288 | 1290 | <ThreadTerminalDrawer |
| 1289 | 1291 | mode="panel" |
| 1292 | visible={visible} | |
| 1290 | 1293 | threadRef={threadRef} |
| 1291 | 1294 | threadId={threadRef.threadId} |
| 1292 | 1295 | cwd={cwd} |
| ... | ... | @@ -7549,6 +7552,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7549 | 7552 | </Suspense> |
| 7550 | 7553 | ) : renderedRightPanelSurface?.kind === "terminal" ? ( |
| 7551 | 7554 | <PersistentThreadTerminalPanel |
| 7555 | visible={rightPanelOpen} | |
| 7552 | 7556 | threadRef={activeThreadRef} |
| 7553 | 7557 | surface={renderedRightPanelSurface} |
| 7554 | 7558 | launchContext={activeTerminalLaunchContext ?? null} |
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5| ... | ... | @@ -33,6 +33,7 @@ import { |
| 33 | 33 | useCallback, |
| 34 | 34 | useEffect, |
| 35 | 35 | useEffectEvent, |
| 36 | useLayoutEffect, | |
| 36 | 37 | useMemo, |
| 37 | 38 | useRef, |
| 38 | 39 | useState, |
| ... | ... | @@ -295,6 +296,7 @@ interface TerminalViewportProps { |
| 295 | 296 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 296 | 297 | focusRequestId: number; |
| 297 | 298 | autoFocus: boolean; |
| 299 | visible: boolean; | |
| 298 | 300 | resizeEpoch: number; |
| 299 | 301 | drawerHeight: number; |
| 300 | 302 | keybindings: ResolvedKeybindingsConfig; |
| ... | ... | @@ -319,12 +321,14 @@ export function TerminalViewport({ |
| 319 | 321 | onAddTerminalContext, |
| 320 | 322 | focusRequestId, |
| 321 | 323 | autoFocus, |
| 324 | visible, | |
| 322 | 325 | resizeEpoch, |
| 323 | 326 | drawerHeight, |
| 324 | 327 | keybindings, |
| 325 | 328 | }: TerminalViewportProps) { |
| 326 | 329 | const containerRef = useRef<HTMLDivElement>(null); |
| 327 | 330 | const terminalRef = useRef<GhosttyTerminalSurface | null>(null); |
| 331 | const visibleRef = useRef(visible); | |
| 328 | 332 | const environmentId = threadRef.environmentId; |
| 329 | 333 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 330 | 334 | const openInPreferredEditor = useOpenInPreferredEditor( |
| ... | ... | @@ -434,6 +438,11 @@ export function TerminalViewport({ |
| 434 | 438 | keybindingsRef.current = keybindings; |
| 435 | 439 | }, [keybindings]); |
| 436 | 440 | |
| 441 | useLayoutEffect(() => { | |
| 442 | visibleRef.current = visible; | |
| 443 | terminalRef.current?.setVisible(visible); | |
| 444 | }, [visible]); | |
| 445 | ||
| 437 | 446 | useEffect(() => { |
| 438 | 447 | const current = terminalFontRef.current; |
| 439 | 448 | if (current.family === terminalFontFamily && current.size === terminalFontSize) return; |
| ... | ... | @@ -457,6 +466,9 @@ export function TerminalViewport({ |
| 457 | 466 | const terminalOptions: GhosttyTerminalSurfaceOptions = { |
| 458 | 467 | theme: terminalThemeFromApp(mount), |
| 459 | 468 | font: terminalFontOptions(setupFont.family, setupFont.size), |
| 469 | get visible() { | |
| 470 | return visibleRef.current; | |
| 471 | }, | |
| 460 | 472 | onData: (data) => handleData(data), |
| 461 | 473 | onResize: (cols, rows) => void resizeTerminal(cols, rows), |
| 462 | 474 | onSelectionChange: () => handleSelectionChange(), |
| ... | ... | @@ -474,6 +486,7 @@ export function TerminalViewport({ |
| 474 | 486 | terminal.dispose(); |
| 475 | 487 | return null; |
| 476 | 488 | } |
| 489 | terminal.setVisible(visibleRef.current); | |
| 477 | 490 | // The theme observer is not installed yet, so re-read the theme in case |
| 478 | 491 | // the app toggled light/dark while the WASM surface was loading. |
| 479 | 492 | terminal.setTheme(terminalThemeFromApp(mount)); |
| ... | ... | @@ -503,7 +516,7 @@ export function TerminalViewport({ |
| 503 | 516 | // never started, so only "exited" triggers the message — as with xterm.) |
| 504 | 517 | synchronizedStatusRef.current = "closed"; |
| 505 | 518 | synchronizeTerminalStatus(terminal, latestSession.status); |
| 506 | if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); | |
| 519 | if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus()); | |
| 507 | 520 | |
| 508 | 521 | const dismissSelectionAction = (supersede = false) => { |
| 509 | 522 | const ownsMenu = |
| ... | ... | @@ -891,7 +904,7 @@ export function TerminalViewport({ |
| 891 | 904 | writeSystemMessage(terminal, current.error); |
| 892 | 905 | } |
| 893 | 906 | |
| 894 | if (previous.version === 0 && autoFocus) { | |
| 907 | if (previous.version === 0 && autoFocus && visibleRef.current) { | |
| 895 | 908 | window.requestAnimationFrame(() => { |
| 896 | 909 | terminal.focus(); |
| 897 | 910 | }); |
| ... | ... | @@ -900,7 +913,7 @@ export function TerminalViewport({ |
| 900 | 913 | }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); |
| 901 | 914 | |
| 902 | 915 | useEffect(() => { |
| 903 | if (!autoFocus) return; | |
| 916 | if (!autoFocus || !visible) return; | |
| 904 | 917 | const terminal = terminalRef.current; |
| 905 | 918 | if (!terminal) return; |
| 906 | 919 | const frame = window.requestAnimationFrame(() => { |
| ... | ... | @@ -909,15 +922,16 @@ export function TerminalViewport({ |
| 909 | 922 | return () => { |
| 910 | 923 | window.cancelAnimationFrame(frame); |
| 911 | 924 | }; |
| 912 | }, [autoFocus, focusRequestId]); | |
| 925 | }, [autoFocus, focusRequestId, visible]); | |
| 913 | 926 | |
| 914 | 927 | useEffect(() => { |
| 915 | 928 | const terminal = terminalRef.current; |
| 916 | if (!terminal) return; | |
| 929 | if (!terminal || !visibleRef.current) return; | |
| 917 | 930 | const wasAtBottom = terminal.isAtBottom(); |
| 918 | 931 | // The surface reports grid changes through onResize, which is the single |
| 919 | 932 | // channel for PTY resize RPCs; fitting here only refreshes the layout. |
| 920 | 933 | const frame = window.requestAnimationFrame(() => { |
| 934 | if (!visibleRef.current) return; | |
| 921 | 935 | terminal.fit(); |
| 922 | 936 | if (wasAtBottom) { |
| 923 | 937 | terminal.scrollToBottom(); |
| ... | ... | @@ -1493,6 +1507,7 @@ export default function ThreadTerminalDrawer({ |
| 1493 | 1507 | onAddTerminalContext={onAddTerminalContext} |
| 1494 | 1508 | focusRequestId={focusRequestId} |
| 1495 | 1509 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 1510 | visible={visible} | |
| 1496 | 1511 | resizeEpoch={resizeEpoch} |
| 1497 | 1512 | drawerHeight={drawerHeight} |
| 1498 | 1513 | keybindings={keybindings} |
| ... | ... | @@ -1522,6 +1537,7 @@ export default function ThreadTerminalDrawer({ |
| 1522 | 1537 | onAddTerminalContext={onAddTerminalContext} |
| 1523 | 1538 | focusRequestId={focusRequestId} |
| 1524 | 1539 | autoFocus |
| 1540 | visible={visible} | |
| 1525 | 1541 | resizeEpoch={resizeEpoch} |
| 1526 | 1542 | drawerHeight={drawerHeight} |
| 1527 | 1543 | keybindings={keybindings} |
apps/web/src/terminal/ghostty/surface.test.ts+322-2| ... | ... | @@ -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 | 4 | import { |
| 5 | 5 | DEFAULT_TERMINAL_FONT_FAMILY, |
| 6 | 6 | DEFAULT_TERMINAL_FONT_SIZE, |
| ... | ... | @@ -31,8 +31,328 @@ import { |
| 31 | 31 | terminalFontSize, |
| 32 | 32 | terminalWheelArrowData, |
| 33 | 33 | terminalWheelDeltaRows, |
| 34 | GhosttyTerminalSurface, | |
| 35 | type GhosttyTerminalSurfaceOptions, | |
| 34 | 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 | 356 | const cell = (text: string): GhosttyCell => ({ |
| 37 | 357 | text, |
| 38 | 358 | wide: 0, |
apps/web/src/terminal/ghostty/surface.ts+54-9| ... | ... | @@ -532,6 +532,8 @@ export interface GhosttySelectionPosition { |
| 532 | 532 | export interface GhosttyTerminalSurfaceOptions { |
| 533 | 533 | readonly theme: GhosttyTheme; |
| 534 | 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 | 537 | readonly onData: (data: string) => void; |
| 536 | 538 | readonly onResize: (cols: number, rows: number) => void; |
| 537 | 539 | readonly onSelectionChange: () => void; |
| ... | ... | @@ -556,6 +558,8 @@ export class GhosttyTerminalSurface { |
| 556 | 558 | private readonly context: CanvasRenderingContext2D; |
| 557 | 559 | private readonly core: GhosttyTerminalCore; |
| 558 | 560 | private readonly options: GhosttyTerminalSurfaceOptions; |
| 561 | private visible: boolean; | |
| 562 | private hasSize = false; | |
| 559 | 563 | private metrics: GhosttyCellMetrics; |
| 560 | 564 | private fontFamily: string; |
| 561 | 565 | private requestedFontFamily: string | undefined; |
| ... | ... | @@ -643,6 +647,7 @@ export class GhosttyTerminalSurface { |
| 643 | 647 | this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); |
| 644 | 648 | this.metrics = metrics; |
| 645 | 649 | this.options = options; |
| 650 | this.visible = options.visible ?? true; | |
| 646 | 651 | this.theme = options.theme; |
| 647 | 652 | this.fontFamily = fontFamily; |
| 648 | 653 | this.requestedFontFamily = options.font?.family; |
| ... | ... | @@ -725,10 +730,24 @@ export class GhosttyTerminalSurface { |
| 725 | 730 | options, |
| 726 | 731 | ); |
| 727 | 732 | surface.fit(); |
| 728 | surface.requestRender(); | |
| 729 | 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 | 751 | write(data: string): void { |
| 733 | 752 | if (this.disposed) return; |
| 734 | 753 | this.core.write(data); |
| ... | ... | @@ -824,10 +843,16 @@ export class GhosttyTerminalSurface { |
| 824 | 843 | }; |
| 825 | 844 | |
| 826 | 845 | fit(): boolean { |
| 827 | if (this.disposed) return false; | |
| 846 | if (this.disposed || !this.visible) return false; | |
| 828 | 847 | const width = this.mount.clientWidth; |
| 829 | 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 | 856 | const ratio = window.devicePixelRatio || 1; |
| 832 | 857 | const pixelWidth = Math.max(1, Math.round(width * ratio)); |
| 833 | 858 | const pixelHeight = Math.max(1, Math.round(height * ratio)); |
| ... | ... | @@ -864,7 +889,7 @@ export class GhosttyTerminalSurface { |
| 864 | 889 | // Rendering synchronously keeps the repaint inside the same frame as the |
| 865 | 890 | // layout change: ResizeObserver fires before paint, so the browser never |
| 866 | 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 | 893 | return true; |
| 869 | 894 | } |
| 870 | 895 | |
| ... | ... | @@ -883,6 +908,7 @@ export class GhosttyTerminalSurface { |
| 883 | 908 | } |
| 884 | 909 | |
| 885 | 910 | focus(): void { |
| 911 | if (this.disposed || !this.visible) return; | |
| 886 | 912 | this.input.focus({ preventScroll: true }); |
| 887 | 913 | } |
| 888 | 914 | |
| ... | ... | @@ -982,8 +1008,7 @@ export class GhosttyTerminalSurface { |
| 982 | 1008 | // the surface unmounts inside the debounce window. |
| 983 | 1009 | this.options.onResize(this.cols, this.rows); |
| 984 | 1010 | } |
| 985 | if (this.frame !== 0) window.cancelAnimationFrame(this.frame); | |
| 986 | if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); | |
| 1011 | this.cancelRender(); | |
| 987 | 1012 | if (this.compositionSuppressionTimer !== null) { |
| 988 | 1013 | window.clearTimeout(this.compositionSuppressionTimer); |
| 989 | 1014 | } |
| ... | ... | @@ -1676,19 +1701,39 @@ export class GhosttyTerminalSurface { |
| 1676 | 1701 | } |
| 1677 | 1702 | |
| 1678 | 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 | 1705 | this.frame = window.requestAnimationFrame(() => { |
| 1681 | 1706 | this.frame = 0; |
| 1682 | 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 | 1722 | private renderFrame(): void { |
| 1687 | if (this.disposed) return; | |
| 1723 | if (this.disposed || !this.visible) return; | |
| 1688 | 1724 | if (this.frame !== 0) { |
| 1689 | 1725 | window.cancelAnimationFrame(this.frame); |
| 1690 | 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 | 1737 | this.snapshot = this.core.snapshot(); |
| 1693 | 1738 | // A cursor that is not blinking right now must be drawn, never caught in an |
| 1694 | 1739 | // off phase left behind by a blink that has since been turned off. |
| ... | ... | @@ -1754,7 +1799,7 @@ export class GhosttyTerminalSurface { |
| 1754 | 1799 | |
| 1755 | 1800 | private blinkEnabled(): boolean { |
| 1756 | 1801 | const snapshot = this.snapshot; |
| 1757 | if (!snapshot) return false; | |
| 1802 | if (!snapshot || !this.visible || !this.hasSize) return false; | |
| 1758 | 1803 | return shouldBlinkTerminalCursor({ |
| 1759 | 1804 | focused: this.focused, |
| 1760 | 1805 | cursorBlinking: snapshot.cursorBlinking, |