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
39 changes: 36 additions & 3 deletions apps/web/src/terminal/ghostty/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,30 @@ export class GhosttyTerminalCore {
this.resize(cols, rows, cellWidth, cellHeight);
}

write(data: string | Uint8Array): void {
write(data: string | Uint8Array, onSynchronizedOutputEnd?: () => void): void {
this.ensureActive();
const bytes = typeof data === "string" ? encoder.encode(data) : data;
if (bytes.length === 0) return;
const pointer = this.runtime.alloc(bytes.length);
this.runtime.bytes(pointer, bytes.length).set(bytes);
this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length);
this.runtime.free(pointer, bytes.length);
// An existing render deadline belongs to one group. Observe Ghostty before
// the next escape can reopen it; Ghostty still parses OSC/DCS and split VT
// sequences itself. Once that group ends, the remaining bytes can be batched.
let synchronized = onSynchronizedOutputEnd !== undefined && this.isSynchronizedOutput();
try {
for (let start = 0; start < bytes.length;) {
const nextEscape = synchronized ? bytes.indexOf(0x1b, start + 1) : -1;
const end = nextEscape === -1 ? bytes.length : nextEscape;
this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer + start, end - start);
start = end;
if (synchronized && !this.isSynchronizedOutput()) {
synchronized = false;
onSynchronizedOutputEnd?.();
}
}
} finally {
this.runtime.free(pointer, bytes.length);
}
}

resetAndWrite(data: string): void {
Expand Down Expand Up @@ -490,6 +506,23 @@ export class GhosttyTerminalCore {
);
}

isSynchronizedOutput(): boolean {
this.ensureActive();
this.runtime.bytes(this.scratch, 1)[0] = 0;
return (
this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2026, this.scratch) ===
GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
);
}

endSynchronizedOutput(): void {
this.ensureActive();
this.assertSuccess(
"ghostty_terminal_mode_set",
this.runtime.call("ghostty_terminal_mode_set", this.terminal, 2026, 0),
);
}

isAlternateScreen(): boolean {
this.ensureActive();
this.runtime.bytes(this.scratch, 4).fill(0);
Expand Down
156 changes: 156 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe("GhosttyTerminalSurface visibility", () => {
const onData = vi.fn<(data: string) => void>();

return {
canvas,
mount,
frames,
paint,
Expand Down Expand Up @@ -271,6 +272,161 @@ describe("GhosttyTerminalSurface visibility", () => {
vi.restoreAllMocks();
});

it("holds the complete frame across split synchronized markers and a DPR change", async () => {
const harness = createHarness();
const surface = await harness.create();
surface.write("\x1b[?1049h\x1b[HOLD first\x1b[2;1HOLD second");
harness.flushFrame();
expect(harness.renderedSnapshot.rowData.slice(0, 2).map((row) => row.text)).toEqual([
"OLD first",
"OLD second",
]);
const { width, height } = harness.canvas;
harness.snapshot.mockClear();
harness.paint.mockClear();

surface.write("\x1b[?20");
surface.write("26h\x1b[HNEW first\x1b[5n");
expect(harness.onData).toHaveBeenCalledWith("\x1b[0n");
harness.flushFrame();
window.devicePixelRatio = 2;
harness.resize();
vi.advanceTimersByTime(200);
surface.write("\x1b[2;1HNEW second\x1b[?2026");
harness.flushFrame();
expect(harness.snapshot).not.toHaveBeenCalled();
expect(harness.paint).not.toHaveBeenCalled();
expect([harness.canvas.width, harness.canvas.height]).toEqual([width, height]);

surface.write("l");
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
expect(harness.renderedSnapshot.rowData.slice(0, 2).map((row) => row.text)).toEqual([
"NEW first",
"NEW second",
]);
expect([harness.canvas.width, harness.canvas.height]).toEqual([width * 2, height * 2]);
expect(
harness.paint.mock.calls
.filter(([operation]) => operation === "fillText")
.map(([, args]) => args[0]),
).toEqual(expect.arrayContaining(["NEW first", "NEW second"]));
vi.advanceTimersByTime(1_000);
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
});

it("recovers an unterminated synchronized update at 500ms despite later output", async () => {
const harness = createHarness();
const surface = await harness.create();
surface.write("before");
harness.flushFrame();
harness.snapshot.mockClear();
harness.paint.mockClear();
surface.write("\x1b[?2026h\rpartial");
vi.advanceTimersByTime(499);
surface.write(" frame");
harness.flushFrame();
expect(harness.snapshot).not.toHaveBeenCalled();
expect(harness.paint).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
expect(harness.renderedSnapshot.rowData[0]?.text).toBe("partial frame");
vi.advanceTimersByTime(1_000);
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
surface.write("!");
harness.flushFrame();
expect(harness.renderedSnapshot.rowData[0]?.text).toBe("partial frame!");
});

it("gives a synchronized group its own deadline when one write closes A and opens B", async () => {
const harness = createHarness();
const surface = await harness.create();
surface.write("old");
harness.flushFrame();
harness.snapshot.mockClear();
harness.paint.mockClear();
surface.write("\x1b[?2026h\rAAA");
vi.advanceTimersByTime(450);
surface.write("\x1b[?2026l\x1b[?2026h\rBBB");
vi.advanceTimersByTime(50);
harness.flushFrame();
expect(harness.snapshot).not.toHaveBeenCalled();
expect(harness.paint).not.toHaveBeenCalled();
vi.advanceTimersByTime(449);
harness.flushFrame();
expect(harness.snapshot).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
expect(harness.renderedSnapshot.rowData[0]?.text).toBe("BBB");
});

it.each(["hide", "zero-size", "dispose"] as const)(
"cancels synchronized recovery on %s",
async (action) => {
const harness = createHarness();
const surface = await harness.create();
surface.write("old");
harness.flushFrame();
surface.write("\x1b[?2026h\rnew");
harness.snapshot.mockClear();
harness.paint.mockClear();
if (action === "hide") surface.setVisible(false);
else if (action === "zero-size") {
harness.mount.clientWidth = 0;
harness.resize();
} else surface.dispose();
vi.advanceTimersByTime(1_000);
harness.flushFrame();
expect(harness.snapshot).not.toHaveBeenCalled();
expect(harness.paint).not.toHaveBeenCalled();
if (action === "dispose") return;
if (action === "hide") surface.setVisible(true);
else {
harness.mount.clientWidth = 168;
harness.resize();
}
expect(harness.snapshot).not.toHaveBeenCalled();
surface.write("\x1b[?2026l");
harness.flushFrame();
expect(harness.renderedSnapshot.rowData[0]?.text).toBe("new");
},
);

it("replaces an open synchronized update on reset without a stale recovery paint", async () => {
const harness = createHarness();
const surface = await harness.create();
surface.write("\x1b[?2026hpartial");
harness.snapshot.mockClear();
surface.resetAndWrite("replacement");
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
expect(harness.renderedSnapshot.rowData[0]?.text).toBe("replacement");
vi.advanceTimersByTime(1_000);
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
});

it("paints a grid resize immediately when Ghostty ends synchronized output", async () => {
const harness = createHarness();
const surface = await harness.create();
surface.write("old");
harness.flushFrame();
surface.write("\x1b[?2026h\rnew");
harness.snapshot.mockClear();
harness.mount.clientWidth = 176;
harness.resize();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
expect(harness.canvas.width).toBe(176);
expect(harness.renderedSnapshot.rowData[0]?.text).toBe("new");
vi.advanceTimersByTime(1_000);
harness.flushFrame();
expect(harness.snapshot).toHaveBeenCalledTimes(1);
});

it.each([
{ platform: "Linux x86_64", primary: { ctrlKey: true } },
{ platform: "MacIntel", primary: { metaKey: true } },
Expand Down
74 changes: 67 additions & 7 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY =
const CONTENT_PADDING = 4;
const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
const CURSOR_BLINK_INTERVAL_MS = 500;
/** End an unterminated synchronized update instead of freezing the last frame. */
const SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS = 500;
const TERMINAL_FONT_LOAD_TEXT = "iMW0@# .";
const TERMINAL_FONT_LOAD_VARIANTS = [
"normal 400",
Expand Down Expand Up @@ -535,6 +537,7 @@ export class GhosttyTerminalSurface {
private readonly scrollbarThumb: HTMLDivElement;
private snapshot: GhosttySnapshot | null = null;
private frame = 0;
private synchronizedRenderTimer: number | null = null;
private cursorTimer: number | null = null;
private compositionInputToSuppress: string | null = null;
private compositionSuppressionTimer: number | null = null;
Expand Down Expand Up @@ -577,6 +580,12 @@ export class GhosttyTerminalSurface {
private focused = false;
private resizeNotified = false;
private canvasConfigured = false;
private canvasDevicePixelRatio = 0;
private pendingCanvasConfiguration: {
readonly width: number;
readonly height: number;
readonly ratio: number;
} | null = null;
private theme: GhosttyTheme;
private readonly suppressedKeyCodes = new Set<string>();
private pasteShortcutToken = 0;
Expand Down Expand Up @@ -723,7 +732,12 @@ export class GhosttyTerminalSurface {

write(data: string): void {
if (this.disposed) return;
this.core.write(data);
this.core.write(
data,
this.synchronizedRenderTimer === null
? undefined
: () => this.cancelSynchronizedRenderTimer(),
);
this.synchronizeMouseTrackingState();
this.cursorOn = true;
this.scrollbarDirty = true;
Expand All @@ -732,6 +746,7 @@ export class GhosttyTerminalSurface {

resetAndWrite(data: string): void {
if (this.disposed) return;
this.cancelSynchronizedRenderTimer();
this.lastMouseMotionData = "";
this.core.resetAndWrite(data);
this.synchronizeMouseTrackingState();
Expand Down Expand Up @@ -837,15 +852,18 @@ export class GhosttyTerminalSurface {
if (
this.canvas.width !== pixelWidth ||
this.canvas.height !== pixelHeight ||
!this.canvasConfigured
!this.canvasConfigured ||
this.canvasDevicePixelRatio !== ratio
) {
this.canvas.width = pixelWidth;
this.canvas.height = pixelHeight;
this.context.setTransform(ratio, 0, 0, ratio, 0, 0);
this.canvasConfigured = true;
// A backing-size change clears the canvas. Apply it with the gated paint
// so a DPR change cannot erase a frame while synchronized output is open.
this.pendingCanvasConfiguration = { width: pixelWidth, height: pixelHeight, ratio };
this.forceFullRender = true;
this.scrollbarDirty = true;
shouldRender = true;
} else {
// A resize can return to the painted size before its queued frame runs.
this.pendingCanvasConfiguration = null;
}
const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING);
this.mountHeight = height;
Expand Down Expand Up @@ -1865,14 +1883,38 @@ export class GhosttyTerminalSurface {
}

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

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

private cancelSynchronizedRenderTimer(): void {
if (this.synchronizedRenderTimer === null) return;
window.clearTimeout(this.synchronizedRenderTimer);
this.synchronizedRenderTimer = null;
}

private deferSynchronizedRender(): boolean {
if (!this.core.isSynchronizedOutput()) {
this.cancelSynchronizedRenderTimer();
return false;
}
// A frame queued before the opening marker would now consume partial rows.
if (this.frame !== 0) {
window.cancelAnimationFrame(this.frame);
this.frame = 0;
Expand All @@ -1881,6 +1923,14 @@ export class GhosttyTerminalSurface {
window.clearTimeout(this.cursorTimer);
this.cursorTimer = null;
}
if (this.synchronizedRenderTimer === null) {
this.synchronizedRenderTimer = window.setTimeout(() => {
this.synchronizedRenderTimer = null;
this.core.endSynchronizedOutput();
this.renderFrame();
}, SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS);
}
return true;
}

private renderFrame(): void {
Expand All @@ -1895,6 +1945,16 @@ export class GhosttyTerminalSurface {
this.cancelRender();
return;
}
if (this.deferSynchronizedRender()) return;
const canvasConfiguration = this.pendingCanvasConfiguration;
if (canvasConfiguration !== null) {
this.pendingCanvasConfiguration = null;
this.canvas.width = canvasConfiguration.width;
this.canvas.height = canvasConfiguration.height;
this.context.setTransform(canvasConfiguration.ratio, 0, 0, canvasConfiguration.ratio, 0, 0);
this.canvasConfigured = true;
this.canvasDevicePixelRatio = canvasConfiguration.ratio;
}
this.snapshot = this.core.snapshot();
if (!this.blinkEnabled()) this.cursorOn = true;
// The origin only moves together with a forced full repaint: partial
Expand Down
Loading