authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-04 12:50:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 12:50:19+02:00
log11639bf4391d9d49e48052bae755f748cbd22ff2
treee95894dda2eeb94e8c3a28aa9fd44125bfc7da74
parenta261a6440ae7c1a063ff23591f43960c7d2b06e5
signature Signed by PGP key B5690EEEBB952194

fix(web): blink the terminal cursor again (#5314)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

5 files changed, 217 insertions(+), 4 deletions(-)

apps/web/src/terminal/ghostty/core.ts+18
...@@ -233,6 +233,7 @@ export class GhosttyTerminalCore {...@@ -233,6 +233,7 @@ export class GhosttyTerminalCore {
233 this.runtime.free(options, optionsSize);233 this.runtime.free(options, optionsSize);
234 this.assertSuccess("ghostty_terminal_new", terminalResult);234 this.assertSuccess("ghostty_terminal_new", terminalResult);
235 this.terminal = this.runtime.readPointer(this.terminalSlot);235 this.terminal = this.runtime.readPointer(this.terminalSlot);
236 this.applyDefaultCursorBlink();
236 this.ptyWriter = onPtyData;237 this.ptyWriter = onPtyData;
237 this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData);238 this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData);
238239
...@@ -302,6 +303,9 @@ export class GhosttyTerminalCore {...@@ -302,6 +303,9 @@ export class GhosttyTerminalCore {
302 resetAndWrite(data: string): void {303 resetAndWrite(data: string): void {
303 this.ensureActive();304 this.ensureActive();
304 this.runtime.call("ghostty_terminal_reset", this.terminal);305 this.runtime.call("ghostty_terminal_reset", this.terminal);
306 // RIS returns the cursor to Ghostty's built-in steady default, so the
307 // embedder default has to be applied again before the replay runs.
308 this.applyDefaultCursorBlink();
305 this.rows = [];309 this.rows = [];
306 if (data.length === 0) return;310 if (data.length === 0) return;
307 const writer = this.ptyWriter;311 const writer = this.ptyWriter;
...@@ -333,6 +337,20 @@ export class GhosttyTerminalCore {...@@ -333,6 +337,20 @@ export class GhosttyTerminalCore {
333 );337 );
334 }338 }
335339
340 /**
341 * Ghostty's built-in default cursor is steady, while the xterm.js renderer
342 * this replaced ran with `cursorBlink: true`. Option 23 is the embedder's
343 * default blink, which is the state a session starts in and returns to on
344 * DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor
345 * through DECSCUSR or DEC mode 12 still win.
346 */
347 private applyDefaultCursorBlink(): void {
348 const blink = this.runtime.alloc(1);
349 this.runtime.bytes(blink, 1)[0] = 1;
350 this.runtime.call("ghostty_terminal_set", this.terminal, 23, blink);
351 this.runtime.free(blink, 1);
352 }
353
336 setTheme(theme: GhosttyTheme): void {354 setTheme(theme: GhosttyTheme): void {
337 this.ensureActive();355 this.ensureActive();
338 const color = this.runtime.alloc(3);356 const color = this.runtime.alloc(3);
apps/web/src/terminal/ghostty/renderer.test.ts+54
...@@ -120,6 +120,60 @@ describe("renderGhosttySnapshot", () => {...@@ -120,6 +120,60 @@ describe("renderGhosttySnapshot", () => {
120 ]);120 ]);
121 });121 });
122122
123 it("repaints the cell without an overlay during the blink off phase", () => {
124 const fillTextCalls: unknown[][] = [];
125 const context = {
126 canvas: { width: 200, height: 40 },
127 beginPath: () => {},
128 clip: () => {},
129 fillRect: () => {},
130 fillText: (...args: unknown[]) => fillTextCalls.push(args),
131 rect: () => {},
132 resetTransform: () => {},
133 restore: () => {},
134 save: () => {},
135 set fillStyle(_value: string) {},
136 set font(_value: string) {},
137 set textBaseline(_value: string) {},
138 } as unknown as CanvasRenderingContext2D;
139 const snapshot: GhosttySnapshot = {
140 cols: 3,
141 rows: 1,
142 foreground: { r: 255, g: 255, b: 255 },
143 background: { r: 0, g: 0, b: 0 },
144 cursor: { r: 255, g: 255, b: 255 },
145 cursorX: 2,
146 cursorY: 0,
147 cursorVisible: true,
148 cursorBlinking: true,
149 cursorStyle: 1,
150 dirtyRows: new Set(),
151 rowData: [
152 {
153 cells: [cell("a"), cell("b"), cell("x")],
154 text: "abx",
155 isWrapContinuation: false,
156 wrapsToNext: false,
157 },
158 ],
159 };
160
161 renderGhosttySnapshot({
162 context,
163 snapshot,
164 metrics: { width: 7.2, height: 16, baseline: 11 },
165 fontSize: 12,
166 fontFamily: "monospace",
167 padding: 4,
168 forceFull: false,
169 cursorOn: false,
170 });
171
172 // The cursor row still repaints so the block disappears, but the inverted
173 // glyph the on phase draws over the cell is gone.
174 expect(fillTextCalls).toEqual([["abx", 4, 15, 21.6]]);
175 });
176
123 it("repaints the previous cursor row after the cursor moves", () => {177 it("repaints the previous cursor row after the cursor moves", () => {
124 const clearedRows: number[] = [];178 const clearedRows: number[] = [];
125 const context = {179 const context = {
apps/web/src/terminal/ghostty/runtimeAbi.test.ts+73
...@@ -83,6 +83,79 @@ describe("vendored libghostty-vt WebAssembly", () => {...@@ -83,6 +83,79 @@ describe("vendored libghostty-vt WebAssembly", () => {
83 }83 }
84 });84 });
8585
86 it("blinks the default cursor until a program asks for a steady one", async () => {
87 const result = await WebAssembly.instantiate(
88 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
89 { env: { log: () => {} } },
90 );
91 const instance = result instanceof WebAssembly.Instance ? result : result.instance;
92 const memory = instance.exports.memory as WebAssembly.Memory;
93 const call = (name: string, ...args: number[]) =>
94 (instance.exports[name] as WasmFunction)(...args);
95 const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
96 const options = alloc(8);
97 const optionsView = new DataView(memory.buffer, options, 8);
98 optionsView.setUint16(0, 80, true);
99 optionsView.setUint16(2, 24, true);
100 const terminalSlot = call("ghostty_wasm_alloc_opaque");
101 expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
102 const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
103 const renderStateSlot = call("ghostty_wasm_alloc_opaque");
104 expect(call("ghostty_render_state_new", 0, renderStateSlot)).toBe(0);
105 const renderState = new DataView(memory.buffer).getUint32(renderStateSlot, true);
106 const scratch = alloc(4);
107
108 const blinking = () => {
109 expect(call("ghostty_render_state_update", renderState, terminal)).toBe(0);
110 expect(call("ghostty_render_state_get", renderState, 12, scratch)).toBe(0);
111 return new DataView(memory.buffer, scratch, 4).getUint8(0) !== 0;
112 };
113 const write = (data: string) => {
114 const bytes = new TextEncoder().encode(data);
115 const pointer = alloc(bytes.length);
116 new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes);
117 call("ghostty_terminal_vt_write", terminal, pointer, bytes.length);
118 call("ghostty_wasm_free_u8_array", pointer, bytes.length);
119 };
120 const setDefaultCursorBlink = (blink: boolean) => {
121 const value = alloc(1);
122 new Uint8Array(memory.buffer, value, 1)[0] = blink ? 1 : 0;
123 expect(call("ghostty_terminal_set", terminal, 23, value)).toBe(0);
124 call("ghostty_wasm_free_u8_array", value, 1);
125 };
126
127 // Ghostty's own default is a steady cursor, so the blink the web terminal
128 // inherited from xterm.js only exists because option 23 asks for it.
129 expect(blinking()).toBe(false);
130 setDefaultCursorBlink(true);
131 expect(blinking()).toBe(true);
132
133 // Programs still own the cursor: DECSCUSR steady block and DEC mode 12 both
134 // stop the blink, and DECSCUSR reset returns to the embedder default.
135 write("\u001b[2 q");
136 expect(blinking()).toBe(false);
137 write("\u001b[0 q");
138 expect(blinking()).toBe(true);
139 write("\u001b[?12l");
140 expect(blinking()).toBe(false);
141 write("\u001b[?12h");
142 expect(blinking()).toBe(true);
143
144 // RIS restores Ghostty's built-in steady default rather than the embedder's,
145 // which is why the core reapplies the option around a session replay.
146 call("ghostty_terminal_reset", terminal);
147 expect(blinking()).toBe(false);
148 setDefaultCursorBlink(true);
149 expect(blinking()).toBe(true);
150
151 call("ghostty_wasm_free_u8_array", scratch, 4);
152 call("ghostty_render_state_free", renderState);
153 call("ghostty_wasm_free_opaque", renderStateSlot);
154 call("ghostty_terminal_free", terminal);
155 call("ghostty_wasm_free_opaque", terminalSlot);
156 call("ghostty_wasm_free_u8_array", options, 8);
157 });
158
86 it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => {159 it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => {
87 const result = await WebAssembly.instantiate(160 const result = await WebAssembly.instantiate(
88 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,161 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
apps/web/src/terminal/ghostty/surface.test.ts+24
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 isTerminalCopyShortcut,11 isTerminalCopyShortcut,
12 isTerminalLinkPointerGesture,12 isTerminalLinkPointerGesture,
13 isTerminalPasteShortcut,13 isTerminalPasteShortcut,
14 shouldBlinkTerminalCursor,
14 shouldReportTerminalMouse,15 shouldReportTerminalMouse,
15 terminalScrollbarGeometry,16 terminalScrollbarGeometry,
16 terminalScrollbarOffsetAtPointer,17 terminalScrollbarOffsetAtPointer,
...@@ -54,6 +55,29 @@ describe("isTerminalAltGraphText", () => {...@@ -54,6 +55,29 @@ describe("isTerminalAltGraphText", () => {
54 });55 });
55});56});
5657
58describe("shouldBlinkTerminalCursor", () => {
59 const blinking = {
60 focused: true,
61 cursorBlinking: true,
62 cursorVisible: true,
63 reducedMotion: false,
64 };
65
66 it("blinks a focused visible cursor the terminal asked to blink", () => {
67 expect(shouldBlinkTerminalCursor(blinking)).toBe(true);
68 });
69
70 it("holds the cursor steady when blinking would be unwanted", () => {
71 // Unfocused surfaces draw a steady hollow cursor, DECSCUSR steady styles and
72 // DEC mode 12 turn blinking off, a hidden cursor has nothing to toggle, and
73 // reduced-motion readers get no permanently animating element.
74 expect(shouldBlinkTerminalCursor({ ...blinking, focused: false })).toBe(false);
75 expect(shouldBlinkTerminalCursor({ ...blinking, cursorBlinking: false })).toBe(false);
76 expect(shouldBlinkTerminalCursor({ ...blinking, cursorVisible: false })).toBe(false);
77 expect(shouldBlinkTerminalCursor({ ...blinking, reducedMotion: true })).toBe(false);
78 });
79});
80
57describe("terminalLinkAtColumn", () => {81describe("terminalLinkAtColumn", () => {
58 it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {82 it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
59 const cells = [83 const cells = [
apps/web/src/terminal/ghostty/surface.ts+48-4
...@@ -31,6 +31,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY =...@@ -31,6 +31,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY =
31 '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;31 '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;
32const CONTENT_PADDING = 4;32const CONTENT_PADDING = 4;
33const MIN_SCROLLBAR_THUMB_HEIGHT = 18;33const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
34/** Half a blink cycle: the visible and hidden phases are equally long. */
35const CURSOR_BLINK_INTERVAL_MS = 500;
3436
35/** Requested terminal font; omitted fields fall back to the defaults. */37/** Requested terminal font; omitted fields fall back to the defaults. */
36export interface GhosttyTerminalFont {38export interface GhosttyTerminalFont {
...@@ -71,6 +73,20 @@ export function terminalFontSize(size?: number): number {...@@ -71,6 +73,20 @@ export function terminalFontSize(size?: number): number {
71 return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));73 return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));
72}74}
7375
76/**
77 * Whether the cursor should keep toggling. An unfocused surface draws a steady
78 * hollow cursor instead of blinking, and a reduced-motion reader gets a steady
79 * cursor too rather than a permanently animating element.
80 */
81export function shouldBlinkTerminalCursor(state: {
82 readonly focused: boolean;
83 readonly cursorBlinking: boolean;
84 readonly cursorVisible: boolean;
85 readonly reducedMotion: boolean;
86}): boolean {
87 return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion;
88}
89
74/**90/**
75 * Vertical origin of the grid inside the mount. While content is shorter than91 * Vertical origin of the grid inside the mount. While content is shorter than
76 * the viewport the grid sits at the top like a fresh terminal. Once scrollback92 * the viewport the grid sits at the top like a fresh terminal. Once scrollback
...@@ -380,6 +396,9 @@ export class GhosttyTerminalSurface {...@@ -380,6 +396,9 @@ export class GhosttyTerminalSurface {
380 private pasteShortcutToken = 0;396 private pasteShortcutToken = 0;
381 private wheelRemainder = 0;397 private wheelRemainder = 0;
382 private dprMedia: MediaQueryList | null = null;398 private dprMedia: MediaQueryList | null = null;
399 // Read live on every blink decision, and watched so that dropping the
400 // preference restarts a blink cycle that has no timer left to notice it.
401 private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)");
383 private inputLeft = -1;402 private inputLeft = -1;
384 private inputTop = -1;403 private inputTop = -1;
385404
...@@ -409,6 +428,7 @@ export class GhosttyTerminalSurface {...@@ -409,6 +428,7 @@ export class GhosttyTerminalSurface {
409 this.resizeObserver = new ResizeObserver(() => this.fit());428 this.resizeObserver = new ResizeObserver(() => this.fit());
410 this.installEvents();429 this.installEvents();
411 this.watchDevicePixelRatio();430 this.watchDevicePixelRatio();
431 this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange);
412 document.fonts.addEventListener("loadingdone", this.onFontsLoaded);432 document.fonts.addEventListener("loadingdone", this.onFontsLoaded);
413 this.resizeObserver.observe(mount);433 this.resizeObserver.observe(mount);
414 }434 }
...@@ -494,6 +514,9 @@ export class GhosttyTerminalSurface {...@@ -494,6 +514,9 @@ export class GhosttyTerminalSurface {
494 resetAndWrite(data: string): void {514 resetAndWrite(data: string): void {
495 if (this.disposed) return;515 if (this.disposed) return;
496 this.core.resetAndWrite(data);516 this.core.resetAndWrite(data);
517 // A replayed session starts from the visible phase like any other write:
518 // reattaching mid-blink must not open on an invisible cursor.
519 this.cursorOn = true;
497 this.forceFullRender = true;520 this.forceFullRender = true;
498 this.scrollbarDirty = true;521 this.scrollbarDirty = true;
499 this.requestRender();522 this.requestRender();
...@@ -537,6 +560,14 @@ export class GhosttyTerminalSurface {...@@ -537,6 +560,14 @@ export class GhosttyTerminalSurface {
537 this.requestRender();560 this.requestRender();
538 }561 }
539562
563 private readonly onReducedMotionChange = () => {
564 if (this.disposed) return;
565 // Nothing else wakes an idle steady cursor: the blink timer only reschedules
566 // from a render, and reduced motion is exactly the state that stopped it.
567 this.cursorOn = true;
568 this.requestRender();
569 };
570
540 private readonly onFontsLoaded = () => {571 private readonly onFontsLoaded = () => {
541 if (this.disposed) return;572 if (this.disposed) return;
542 // A face that finished loading after the initial measurement changes glyph573 // A face that finished loading after the initial measurement changes glyph
...@@ -679,6 +710,7 @@ export class GhosttyTerminalSurface {...@@ -679,6 +710,7 @@ export class GhosttyTerminalSurface {
679 document.fonts.removeEventListener("loadingdone", this.onFontsLoaded);710 document.fonts.removeEventListener("loadingdone", this.onFontsLoaded);
680 this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);711 this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);
681 this.dprMedia = null;712 this.dprMedia = null;
713 this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange);
682 if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer);714 if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer);
683 if (this.resizeNotifyTimer !== null) {715 if (this.resizeNotifyTimer !== null) {
684 window.clearTimeout(this.resizeNotifyTimer);716 window.clearTimeout(this.resizeNotifyTimer);
...@@ -1249,7 +1281,9 @@ export class GhosttyTerminalSurface {...@@ -1249,7 +1281,9 @@ export class GhosttyTerminalSurface {
1249 this.frame = 0;1281 this.frame = 0;
1250 }1282 }
1251 this.snapshot = this.core.snapshot();1283 this.snapshot = this.core.snapshot();
1252 if (!this.snapshot.cursorBlinking) this.cursorOn = true;1284 // A cursor that is not blinking right now must be drawn, never caught in an
1285 // off phase left behind by a blink that has since been turned off.
1286 if (!this.blinkEnabled()) this.cursorOn = true;
1253 // The origin only moves together with a forced full repaint: partial1287 // The origin only moves together with a forced full repaint: partial
1254 // dirty-row redraws must never composite rows at a shifted origin over1288 // dirty-row redraws must never composite rows at a shifted origin over
1255 // rows painted at the previous one. Bottom anchoring starts once1289 // rows painted at the previous one. Bottom anchoring starts once
...@@ -1299,13 +1333,23 @@ export class GhosttyTerminalSurface {...@@ -1299,13 +1333,23 @@ export class GhosttyTerminalSurface {
1299 private scheduleCursorBlink(): void {1333 private scheduleCursorBlink(): void {
1300 if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);1334 if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
1301 this.cursorTimer = null;1335 this.cursorTimer = null;
1302 // An unfocused surface shows a steady hollow cursor instead of blinking.1336 if (!this.blinkEnabled()) return;
1303 if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return;
1304 this.cursorTimer = window.setTimeout(() => {1337 this.cursorTimer = window.setTimeout(() => {
1305 this.cursorTimer = null;1338 this.cursorTimer = null;
1306 this.cursorOn = !this.cursorOn;1339 this.cursorOn = !this.cursorOn;
1307 this.requestRender();1340 this.requestRender();
1308 }, 500);1341 }, CURSOR_BLINK_INTERVAL_MS);
1342 }
1343
1344 private blinkEnabled(): boolean {
1345 const snapshot = this.snapshot;
1346 if (!snapshot) return false;
1347 return shouldBlinkTerminalCursor({
1348 focused: this.focused,
1349 cursorBlinking: snapshot.cursorBlinking,
1350 cursorVisible: snapshot.cursorVisible,
1351 reducedMotion: this.reducedMotionMedia?.matches ?? false,
1352 });
1309 }1353 }
13101354
1311 private positionInput(): void {1355 private positionInput(): void {