| author | |
| committer | |
| log | 11639bf4391d9d49e48052bae755f748cbd22ff2 |
| tree | e95894dda2eeb94e8c3a28aa9fd44125bfc7da74 |
| parent | a261a6440ae7c1a063ff23591f43960c7d2b06e5 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 233 | this.runtime.free(options, optionsSize); |
| 234 | 234 | this.assertSuccess("ghostty_terminal_new", terminalResult); |
| 235 | 235 | this.terminal = this.runtime.readPointer(this.terminalSlot); |
| 236 | this.applyDefaultCursorBlink(); | |
| 236 | 237 | this.ptyWriter = onPtyData; |
| 237 | 238 | this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); |
| 238 | 239 | |
| ... | ... | @@ -302,6 +303,9 @@ export class GhosttyTerminalCore { |
| 302 | 303 | resetAndWrite(data: string): void { |
| 303 | 304 | this.ensureActive(); |
| 304 | 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 | 309 | this.rows = []; |
| 306 | 310 | if (data.length === 0) return; |
| 307 | 311 | const writer = this.ptyWriter; |
| ... | ... | @@ -333,6 +337,20 @@ export class GhosttyTerminalCore { |
| 333 | 337 | ); |
| 334 | 338 | } |
| 335 | 339 | |
| 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 | 354 | setTheme(theme: GhosttyTheme): void { |
| 337 | 355 | this.ensureActive(); |
| 338 | 356 | const color = this.runtime.alloc(3); |
apps/web/src/terminal/ghostty/renderer.test.ts+54| ... | ... | @@ -120,6 +120,60 @@ describe("renderGhosttySnapshot", () => { |
| 120 | 120 | ]); |
| 121 | 121 | }); |
| 122 | 122 | |
| 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 | 177 | it("repaints the previous cursor row after the cursor moves", () => { |
| 124 | 178 | const clearedRows: number[] = []; |
| 125 | 179 | const context = { |
apps/web/src/terminal/ghostty/runtimeAbi.test.ts+73| ... | ... | @@ -83,6 +83,79 @@ describe("vendored libghostty-vt WebAssembly", () => { |
| 83 | 83 | } |
| 84 | 84 | }); |
| 85 | 85 | |
| 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 | 159 | it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => { |
| 87 | 160 | const result = await WebAssembly.instantiate( |
| 88 | 161 | decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, |
apps/web/src/terminal/ghostty/surface.test.ts+24| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | isTerminalCopyShortcut, |
| 12 | 12 | isTerminalLinkPointerGesture, |
| 13 | 13 | isTerminalPasteShortcut, |
| 14 | shouldBlinkTerminalCursor, | |
| 14 | 15 | shouldReportTerminalMouse, |
| 15 | 16 | terminalScrollbarGeometry, |
| 16 | 17 | terminalScrollbarOffsetAtPointer, |
| ... | ... | @@ -54,6 +55,29 @@ describe("isTerminalAltGraphText", () => { |
| 54 | 55 | }); |
| 55 | 56 | }); |
| 56 | 57 | |
| 58 | describe("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 | ||
| 57 | 81 | describe("terminalLinkAtColumn", () => { |
| 58 | 82 | it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { |
| 59 | 83 | const cells = [ |
apps/web/src/terminal/ghostty/surface.ts+48-4| ... | ... | @@ -31,6 +31,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = |
| 31 | 31 | '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; |
| 32 | 32 | const CONTENT_PADDING = 4; |
| 33 | 33 | const MIN_SCROLLBAR_THUMB_HEIGHT = 18; |
| 34 | /** Half a blink cycle: the visible and hidden phases are equally long. */ | |
| 35 | const CURSOR_BLINK_INTERVAL_MS = 500; | |
| 34 | 36 | |
| 35 | 37 | /** Requested terminal font; omitted fields fall back to the defaults. */ |
| 36 | 38 | export interface GhosttyTerminalFont { |
| ... | ... | @@ -71,6 +73,20 @@ export function terminalFontSize(size?: number): number { |
| 71 | 73 | return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); |
| 72 | 74 | } |
| 73 | 75 | |
| 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 | */ | |
| 81 | export 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 | 91 | * Vertical origin of the grid inside the mount. While content is shorter than |
| 76 | 92 | * the viewport the grid sits at the top like a fresh terminal. Once scrollback |
| ... | ... | @@ -380,6 +396,9 @@ export class GhosttyTerminalSurface { |
| 380 | 396 | private pasteShortcutToken = 0; |
| 381 | 397 | private wheelRemainder = 0; |
| 382 | 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 | 402 | private inputLeft = -1; |
| 384 | 403 | private inputTop = -1; |
| 385 | 404 | |
| ... | ... | @@ -409,6 +428,7 @@ export class GhosttyTerminalSurface { |
| 409 | 428 | this.resizeObserver = new ResizeObserver(() => this.fit()); |
| 410 | 429 | this.installEvents(); |
| 411 | 430 | this.watchDevicePixelRatio(); |
| 431 | this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); | |
| 412 | 432 | document.fonts.addEventListener("loadingdone", this.onFontsLoaded); |
| 413 | 433 | this.resizeObserver.observe(mount); |
| 414 | 434 | } |
| ... | ... | @@ -494,6 +514,9 @@ export class GhosttyTerminalSurface { |
| 494 | 514 | resetAndWrite(data: string): void { |
| 495 | 515 | if (this.disposed) return; |
| 496 | 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 | 520 | this.forceFullRender = true; |
| 498 | 521 | this.scrollbarDirty = true; |
| 499 | 522 | this.requestRender(); |
| ... | ... | @@ -537,6 +560,14 @@ export class GhosttyTerminalSurface { |
| 537 | 560 | this.requestRender(); |
| 538 | 561 | } |
| 539 | 562 | |
| 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 | 571 | private readonly onFontsLoaded = () => { |
| 541 | 572 | if (this.disposed) return; |
| 542 | 573 | // A face that finished loading after the initial measurement changes glyph |
| ... | ... | @@ -679,6 +710,7 @@ export class GhosttyTerminalSurface { |
| 679 | 710 | document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); |
| 680 | 711 | this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); |
| 681 | 712 | this.dprMedia = null; |
| 713 | this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); | |
| 682 | 714 | if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); |
| 683 | 715 | if (this.resizeNotifyTimer !== null) { |
| 684 | 716 | window.clearTimeout(this.resizeNotifyTimer); |
| ... | ... | @@ -1249,7 +1281,9 @@ export class GhosttyTerminalSurface { |
| 1249 | 1281 | this.frame = 0; |
| 1250 | 1282 | } |
| 1251 | 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 | 1287 | // The origin only moves together with a forced full repaint: partial |
| 1254 | 1288 | // dirty-row redraws must never composite rows at a shifted origin over |
| 1255 | 1289 | // rows painted at the previous one. Bottom anchoring starts once |
| ... | ... | @@ -1299,13 +1333,23 @@ export class GhosttyTerminalSurface { |
| 1299 | 1333 | private scheduleCursorBlink(): void { |
| 1300 | 1334 | if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); |
| 1301 | 1335 | this.cursorTimer = null; |
| 1302 | // An unfocused surface shows a steady hollow cursor instead of blinking. | |
| 1303 | if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return; | |
| 1336 | if (!this.blinkEnabled()) return; | |
| 1304 | 1337 | this.cursorTimer = window.setTimeout(() => { |
| 1305 | 1338 | this.cursorTimer = null; |
| 1306 | 1339 | this.cursorOn = !this.cursorOn; |
| 1307 | 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 | } |
| 1310 | 1354 | |
| 1311 | 1355 | private positionInput(): void { |