From 251c83363846af4f61a8efb68007b0579e3375c8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:46:16 +0200 Subject: [PATCH 1/4] fix(web): restore terminal link hover styles --- .../web/src/terminal/ghostty/renderer.test.ts | 56 +++++++++ apps/web/src/terminal/ghostty/renderer.ts | 21 +++- apps/web/src/terminal/ghostty/surface.test.ts | 12 ++ apps/web/src/terminal/ghostty/surface.ts | 119 ++++++++++++++++-- 4 files changed, 197 insertions(+), 11 deletions(-) diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index f3de1725a60..5f5c41c8fec 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -71,6 +71,62 @@ describe("ghosttyTextRunEnd", () => { }); describe("renderGhosttySnapshot", () => { + it("underlines every cell in a hovered wrapped link", () => { + const fillRectCalls: number[][] = []; + const context = { + canvas: { width: 200, height: 80 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push(args), + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 4, + rows: 2, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0, 1]), + rowData: [0, 1].map(() => ({ + cells: [cell("a"), cell("b"), cell("c"), cell("d")], + text: "abcd", + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 10, height: 20, baseline: 15 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } }, + }); + + expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([ + [24, 22, 10, 1], + [34, 22, 10, 1], + [4, 42, 10, 1], + [14, 42, 10, 1], + ]); + }); + it("constrains text runs and cursor glyphs to their terminal cells", () => { const fillTextCalls: unknown[][] = []; const context = { diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 0cb7d6f535d..9d47718464e 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -12,6 +12,11 @@ export interface GhosttyCellMetrics { readonly baseline: number; } +export interface GhosttyCellRange { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; function cssColor(color: GhosttyColor): string { @@ -98,6 +103,7 @@ export function renderGhosttySnapshot(options: { readonly previousCursorY?: number | null; readonly focused?: boolean; readonly selectionBackground?: string; + readonly hoveredLinkRange?: GhosttyCellRange | null; /** Vertical origin of row 0; defaults to the horizontal padding. */ readonly originY?: number; }): void { @@ -114,6 +120,7 @@ export function renderGhosttySnapshot(options: { } = options; const focused = options.focused ?? true; const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const hoveredLinkRange = options.hoveredLinkRange ?? null; const originY = options.originY ?? padding; const rowsToDraw = forceFull ? Array.from({ length: snapshot.rows }, (_, index) => index) @@ -216,10 +223,20 @@ export function renderGhosttySnapshot(options: { for (let column = 0; column < row.cells.length; column += 1) { const cell = row.cells[column]; - if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue; + const hoveredLink = + hoveredLinkRange !== null && + rowIndex >= hoveredLinkRange.start.y && + rowIndex <= hoveredLinkRange.end.y && + (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) && + (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x); + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) { + continue; + } context.fillStyle = cssColor(cell.foreground); const left = padding + column * metrics.width; - if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + if (cell.underline || hoveredLink) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + } if (cell.strikethrough) { context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7f94a4c95c8..a1315c9b4c6 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -17,6 +17,7 @@ import { terminalScrollbarOffsetAtPointer, terminalLinkAtColumn, terminalLinkAtPosition, + terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, fittedTerminalFontSize, @@ -99,6 +100,10 @@ describe("terminalLinkAtColumn", () => { expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); expect(terminalLinkAtColumn(row, 0)).toBeNull(); + expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({ + start: { x: 2, y: 0 }, + end: { x: cells.length - 1, y: 0 }, + }); }); it("uses shared path matching and reconstructs soft-wrapped links", () => { @@ -119,6 +124,13 @@ describe("terminalLinkAtColumn", () => { expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ + text: "https://example.com/reference", + range: { + start: { x: 0, y: 0 }, + end: { x: 12, y: 1 }, + }, + }); }); it("refuses links truncated at the viewport edges instead of mis-resolving", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index b460d38d2df..ac8aaa44d05 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -10,6 +10,7 @@ import { measureGhosttyCell, renderGhosttySnapshot, terminalGridSize, + type GhosttyCellRange, type GhosttyCellMetrics, } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; @@ -223,6 +224,27 @@ export function terminalLinkAtPosition( rowIndex: number, column: number, ): string | null { + return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; +} + +export interface TerminalLinkWithRange { + readonly text: string; + readonly range: GhosttyCellRange; +} + +function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { + for (let column = 0; column < row.cells.length; column += 1) { + const nextOffset = terminalColumnOffset(row, column + 1); + if (offset < nextOffset) return column; + } + return Math.max(0, row.cells.length - 1); +} + +export function terminalLinkAtPositionWithRange( + rows: GhosttySnapshot["rowData"], + rowIndex: number, + column: number, +): TerminalLinkWithRange | null { const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { const row = rows[index]; if (!row) return null; @@ -251,7 +273,28 @@ export function terminalLinkAtPosition( if (offset >= match.start && offset < match.end) { // A truncated tail must not activate as a complete link. if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; - return match.text; + const startSegment = wrappedLine.segments.find( + (value) => match.start >= value.startIndex && match.start < value.endIndex, + ); + const endSegment = wrappedLine.segments.find( + (value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex, + ); + const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; + const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; + if (!startSegment || !endSegment || !startRow || !endRow) return null; + return { + text: match.text, + range: { + start: { + x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex), + y: startSegment.bufferLineNumber - 1, + }, + end: { + x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + y: endSegment.bufferLineNumber - 1, + }, + }, + }; } } return null; @@ -444,6 +487,8 @@ export class GhosttyTerminalSurface { private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; + private hoveredLink: TerminalLinkWithRange | null = null; + private hoverPointer: { x: number; y: number } | null = null; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -1097,10 +1142,33 @@ export class GhosttyTerminalSurface { } private updateHoverCursor(event: PointerEvent): void { - const overLink = - isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null; - const cursor = overLink ? "pointer" : ""; - if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor; + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.refreshHoveredLink(); + } + + private readonly onPointerLeave = () => { + this.hoverPointer = null; + this.setHoveredLink(null); + }; + + private refreshHoveredLink(): void { + const pointer = this.hoverPointer; + this.setHoveredLink(pointer ? this.linkAt(pointer.x, pointer.y) : null); + } + + private setHoveredLink(link: TerminalLinkWithRange | null): void { + const previous = this.hoveredLink; + const unchanged = + previous?.text === link?.text && + previous?.range.start.x === link?.range.start.x && + previous?.range.start.y === link?.range.start.y && + previous?.range.end.x === link?.range.end.x && + previous?.range.end.y === link?.range.end.y; + this.canvas.style.cursor = link ? "pointer" : ""; + if (unchanged) return; + this.hoveredLink = link; + this.forceFullRender = true; + this.requestRender(); } private readonly onPointerUp = (event: PointerEvent) => { @@ -1114,7 +1182,7 @@ export class GhosttyTerminalSurface { } if (event.type !== "pointercancel") { const link = this.linkAt(event.clientX, event.clientY); - if (link) this.options.onLinkActivate(link, event); + if (link) this.options.onLinkActivate(link.text, event); } return; } @@ -1253,6 +1321,7 @@ export class GhosttyTerminalSurface { this.input.addEventListener("compositionend", this.onCompositionEnd); this.canvas.addEventListener("pointerdown", this.onPointerDown); this.canvas.addEventListener("pointermove", this.onPointerMove); + this.canvas.addEventListener("pointerleave", this.onPointerLeave); this.canvas.addEventListener("pointerup", this.onPointerUp); this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); @@ -1276,6 +1345,7 @@ export class GhosttyTerminalSurface { this.input.removeEventListener("compositionend", this.onCompositionEnd); this.canvas.removeEventListener("pointerdown", this.onPointerDown); this.canvas.removeEventListener("pointermove", this.onPointerMove); + this.canvas.removeEventListener("pointerleave", this.onPointerLeave); this.canvas.removeEventListener("pointerup", this.onPointerUp); this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); @@ -1358,6 +1428,7 @@ export class GhosttyTerminalSurface { this.frame = 0; } this.snapshot = this.core.snapshot(); + this.refreshHoveredLink(); // A cursor that is not blinking right now must be drawn, never caught in an // off phase left behind by a blink that has since been turned off. if (!this.blinkEnabled()) this.cursorOn = true; @@ -1390,6 +1461,7 @@ export class GhosttyTerminalSurface { cursorOn: this.cursorOn, previousCursorY: this.renderedCursorY, focused: this.focused, + hoveredLinkRange: this.hoveredLink?.range ?? null, ...(this.theme.selectionBackground !== undefined ? { selectionBackground: this.theme.selectionBackground } : {}), @@ -1466,12 +1538,41 @@ export class GhosttyTerminalSurface { }; } - private linkAt(clientX: number, clientY: number): string | null { + private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { if (!this.snapshot) return null; const cell = this.cellAt(clientX, clientY); const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); - if (explicitHyperlink) return explicitHyperlink; - return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x); + if (explicitHyperlink) { + const start = { ...cell }; + const end = { ...cell }; + while (true) { + const previous = + start.x > 0 + ? { x: start.x - 1, y: start.y } + : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation + ? { x: this.cols - 1, y: start.y - 1 } + : null; + if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break; + start.x = previous.x; + start.y = previous.y; + } + while (true) { + const next = + end.x + 1 < this.cols + ? { x: end.x + 1, y: end.y } + : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext + ? { x: 0, y: end.y + 1 } + : null; + if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break; + end.x = next.x; + end.y = next.y; + } + return { + text: explicitHyperlink, + range: { start, end }, + }; + } + return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); } private sendMouse( From 138e0cf0c082069e535409b019486e9f1c92702b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:53:53 +0200 Subject: [PATCH 2/4] fix(web): tighten terminal link hover tracking --- apps/web/src/terminal/ghostty/surface.test.ts | 27 ++++++++ apps/web/src/terminal/ghostty/surface.ts | 62 ++++++++++++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index a1315c9b4c6..a384b0369cf 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -13,6 +13,7 @@ import { isTerminalPasteShortcut, shouldBlinkTerminalCursor, shouldReportTerminalMouse, + terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, terminalLinkAtColumn, @@ -57,6 +58,32 @@ describe("isTerminalAltGraphText", () => { }); }); +describe("terminalGridCellAt", () => { + const options = { + bounds: { left: 100, top: 200 }, + cols: 3, + rows: 2, + metrics: { width: 10, height: 20 }, + padding: 4, + originY: 24, + }; + + it("maps points inside the rendered grid without clamping its padding", () => { + expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 224 })).toEqual({ + x: 0, + y: 0, + }); + expect(terminalGridCellAt({ ...options, clientX: 133, clientY: 263 })).toEqual({ + x: 2, + y: 1, + }); + expect(terminalGridCellAt({ ...options, clientX: 103, clientY: 224 })).toBeNull(); + expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 223 })).toBeNull(); + expect(terminalGridCellAt({ ...options, clientX: 134, clientY: 224 })).toBeNull(); + expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 264 })).toBeNull(); + }); +}); + describe("shouldBlinkTerminalCursor", () => { const blinking = { focused: true, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index ac8aaa44d05..7ee9cf4871b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -206,6 +206,28 @@ export function terminalScrollbarOffsetAtPointer( return Math.round((thumbTop / travel) * geometry.maxOffset); } +export function terminalGridCellAt(options: { + bounds: { left: number; top: number }; + clientX: number; + clientY: number; + cols: number; + rows: number; + metrics: Pick; + padding: number; + originY: number; +}): { x: number; y: number } | null { + const { bounds, clientX, clientY, cols, rows, metrics, padding, originY } = options; + const gridX = clientX - bounds.left - padding; + const gridY = clientY - bounds.top - originY; + if (gridX < 0 || gridY < 0 || gridX >= cols * metrics.width || gridY >= rows * metrics.height) { + return null; + } + return { + x: Math.floor(gridX / metrics.width), + y: Math.floor(gridY / metrics.height), + }; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { const text = row.cells.map((cell) => cell.text || " ").join(""); return trimRight ? text.trimEnd() : text; @@ -488,7 +510,7 @@ export class GhosttyTerminalSurface { private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; private hoveredLink: TerminalLinkWithRange | null = null; - private hoverPointer: { x: number; y: number } | null = null; + private hoverPointer: { x: number; y: number; allowInMouseTracking: boolean } | null = null; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -1013,6 +1035,7 @@ export class GhosttyTerminalSurface { if (button === null) return; event.preventDefault(); event.stopPropagation(); + this.clearHoveredLink("default"); this.mouseReportingPointerId = event.pointerId; this.mouseReportingButton = button; this.sendMouse("press", button, event); @@ -1027,6 +1050,7 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + this.clearHoveredLink(); const cell = this.cellAt(event.clientX, event.clientY); this.selectionMoved = false; this.selectionClickSequence = advanceTerminalSelectionClickSequence( @@ -1074,7 +1098,7 @@ export class GhosttyTerminalSurface { shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) ) { event.preventDefault(); - this.canvas.style.cursor = "default"; + this.clearHoveredLink("default"); this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); return; } @@ -1082,6 +1106,7 @@ export class GhosttyTerminalSurface { this.updateHoverCursor(event); return; } + this.clearHoveredLink(); this.selectionPointer = { x: event.clientX, y: event.clientY }; const bounds = this.canvas.getBoundingClientRect(); this.setSelectionAutoscroll( @@ -1142,18 +1167,31 @@ export class GhosttyTerminalSurface { } private updateHoverCursor(event: PointerEvent): void { - this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.hoverPointer = { + x: event.clientX, + y: event.clientY, + allowInMouseTracking: isTerminalLinkPointerGesture(event), + }; this.refreshHoveredLink(); } private readonly onPointerLeave = () => { + this.clearHoveredLink(); + }; + + private clearHoveredLink(cursor = ""): void { this.hoverPointer = null; this.setHoveredLink(null); - }; + this.canvas.style.cursor = cursor; + } private refreshHoveredLink(): void { const pointer = this.hoverPointer; - this.setHoveredLink(pointer ? this.linkAt(pointer.x, pointer.y) : null); + const link = + pointer && (!this.core.isMouseTracking() || pointer.allowInMouseTracking) + ? this.linkAt(pointer.x, pointer.y) + : null; + this.setHoveredLink(link); } private setHoveredLink(link: TerminalLinkWithRange | null): void { @@ -1428,7 +1466,6 @@ export class GhosttyTerminalSurface { this.frame = 0; } this.snapshot = this.core.snapshot(); - this.refreshHoveredLink(); // A cursor that is not blinking right now must be drawn, never caught in an // off phase left behind by a blink that has since been turned off. if (!this.blinkEnabled()) this.cursorOn = true; @@ -1449,6 +1486,7 @@ export class GhosttyTerminalSurface { this.originY = nextOriginY; this.forceFullRender = true; } + this.refreshHoveredLink(); renderGhosttySnapshot({ context: this.context, snapshot: this.snapshot, @@ -1540,7 +1578,17 @@ export class GhosttyTerminalSurface { private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { if (!this.snapshot) return null; - const cell = this.cellAt(clientX, clientY); + const cell = terminalGridCellAt({ + bounds: this.canvas.getBoundingClientRect(), + clientX, + clientY, + cols: this.cols, + rows: this.rows, + metrics: this.metrics, + padding: CONTENT_PADDING, + originY: this.originY, + }); + if (!cell) return null; const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); if (explicitHyperlink) { const start = { ...cell }; From f2e6b6e252cb8d59a0335f63e0b7ed9fb167cedb Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:00:35 +0200 Subject: [PATCH 3/4] fix(web): sync terminal link hover modifiers --- apps/web/src/terminal/ghostty/surface.test.ts | 8 +++++ apps/web/src/terminal/ghostty/surface.ts | 35 ++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index a384b0369cf..f1559ac1578 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -13,6 +13,7 @@ import { isTerminalPasteShortcut, shouldBlinkTerminalCursor, shouldReportTerminalMouse, + shouldShowTerminalLinkHover, terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, @@ -284,6 +285,13 @@ describe("application mouse reporting", () => { it("maps browser buttons to Ghostty's button enum", () => { expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]); }); + + it("only shows link hover during mouse tracking when the link modifier is held", () => { + expect(shouldShowTerminalLinkHover(false, false)).toBe(true); + expect(shouldShowTerminalLinkHover(false, true)).toBe(true); + expect(shouldShowTerminalLinkHover(true, false)).toBe(false); + expect(shouldShowTerminalLinkHover(true, true)).toBe(true); + }); }); describe("terminal font resolution", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 7ee9cf4871b..229bb6f0146 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -403,6 +403,13 @@ export function isTerminalLinkPointerGesture( : event.ctrlKey && !event.metaKey; } +export function shouldShowTerminalLinkHover( + mouseTracking: boolean, + linkModifierActive: boolean, +): boolean { + return !mouseTracking || linkModifierActive; +} + export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: @@ -510,7 +517,8 @@ export class GhosttyTerminalSurface { private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; private hoveredLink: TerminalLinkWithRange | null = null; - private hoverPointer: { x: number; y: number; allowInMouseTracking: boolean } | null = null; + private hoverPointer: { x: number; y: number } | null = null; + private linkModifierActive = false; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -882,6 +890,7 @@ export class GhosttyTerminalSurface { } private readonly onKeyDown = (event: KeyboardEvent) => { + this.updateLinkModifier(event); // Presses handled outside the terminal must also swallow their release: // beforeKey runs side effects (keybindings, navigation sends), so it cannot // be consulted again on keyup, and Kitty report-event-types sessions would @@ -933,6 +942,7 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { + this.updateLinkModifier(event); if (this.suppressedKeyCodes.delete(event.code)) return; if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { return; @@ -954,6 +964,8 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + this.linkModifierActive = false; + this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes // back before release. Stale entries are harmless — an encoding keydown @@ -1098,7 +1110,10 @@ export class GhosttyTerminalSurface { shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) ) { event.preventDefault(); - this.clearHoveredLink("default"); + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.setHoveredLink(null); + this.canvas.style.cursor = "default"; this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); return; } @@ -1167,11 +1182,15 @@ export class GhosttyTerminalSurface { } private updateHoverCursor(event: PointerEvent): void { - this.hoverPointer = { - x: event.clientX, - y: event.clientY, - allowInMouseTracking: isTerminalLinkPointerGesture(event), - }; + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } + + private updateLinkModifier(event: Pick): void { + const active = isTerminalLinkPointerGesture(event); + if (active === this.linkModifierActive) return; + this.linkModifierActive = active; this.refreshHoveredLink(); } @@ -1188,7 +1207,7 @@ export class GhosttyTerminalSurface { private refreshHoveredLink(): void { const pointer = this.hoverPointer; const link = - pointer && (!this.core.isMouseTracking() || pointer.allowInMouseTracking) + pointer && shouldShowTerminalLinkHover(this.core.isMouseTracking(), this.linkModifierActive) ? this.linkAt(pointer.x, pointer.y) : null; this.setHoveredLink(link); From b8e574736a35f33d29a9b9df10efd3f22a0ee868 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:08:28 +0200 Subject: [PATCH 4/4] fix(web): refresh link hover after mouse release --- apps/web/src/terminal/ghostty/surface.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 229bb6f0146..853da9c139c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1112,6 +1112,8 @@ export class GhosttyTerminalSurface { event.preventDefault(); this.hoverPointer = { x: event.clientX, y: event.clientY }; this.linkModifierActive = isTerminalLinkPointerGesture(event); + // A drag whose press was already sent to the terminal application cannot + // turn into link activation midway through, so link feedback would lie. this.setHoveredLink(null); this.canvas.style.cursor = "default"; this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); @@ -1252,6 +1254,13 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.type === "pointercancel") { + this.clearHoveredLink(); + } else { + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } return; } if (this.canvas.hasPointerCapture(event.pointerId)) {