authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-05 11:20:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 11:20:17+02:00
log9d9a872bccc6ed099de85a121ab042c9e078987e
tree9d2e3af3556ca4472e5057d76326485836ed1e4d
parent9235c83eb7f16c321ad02aa4cc502fbe0be252c1
signature Signed by PGP key B5690EEEBB952194

fix(web): restore terminal link hover styles (#5382)


4 files changed, 309 insertions(+), 12 deletions(-)

apps/web/src/terminal/ghostty/renderer.test.ts+56
......@@ -71,6 +71,62 @@ describe("ghosttyTextRunEnd", () => {
7171});
7272
7373describe("renderGhosttySnapshot", () => {
74 it("underlines every cell in a hovered wrapped link", () => {
75 const fillRectCalls: number[][] = [];
76 const context = {
77 canvas: { width: 200, height: 80 },
78 beginPath: () => {},
79 clip: () => {},
80 fillRect: (...args: number[]) => fillRectCalls.push(args),
81 fillText: () => {},
82 rect: () => {},
83 resetTransform: () => {},
84 restore: () => {},
85 save: () => {},
86 set fillStyle(_value: string) {},
87 set font(_value: string) {},
88 set textBaseline(_value: string) {},
89 } as unknown as CanvasRenderingContext2D;
90 const snapshot: GhosttySnapshot = {
91 cols: 4,
92 rows: 2,
93 foreground: { r: 255, g: 255, b: 255 },
94 background: { r: 0, g: 0, b: 0 },
95 cursor: { r: 255, g: 255, b: 255 },
96 cursorX: -1,
97 cursorY: -1,
98 cursorVisible: false,
99 cursorBlinking: false,
100 cursorStyle: 1,
101 dirtyRows: new Set([0, 1]),
102 rowData: [0, 1].map(() => ({
103 cells: [cell("a"), cell("b"), cell("c"), cell("d")],
104 text: "abcd",
105 isWrapContinuation: false,
106 wrapsToNext: false,
107 })),
108 };
109
110 renderGhosttySnapshot({
111 context,
112 snapshot,
113 metrics: { width: 10, height: 20, baseline: 15 },
114 fontSize: 12,
115 fontFamily: "monospace",
116 padding: 4,
117 forceFull: false,
118 cursorOn: false,
119 hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } },
120 });
121
122 expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([
123 [24, 22, 10, 1],
124 [34, 22, 10, 1],
125 [4, 42, 10, 1],
126 [14, 42, 10, 1],
127 ]);
128 });
129
74130 it("constrains text runs and cursor glyphs to their terminal cells", () => {
75131 const fillTextCalls: unknown[][] = [];
76132 const context = {
apps/web/src/terminal/ghostty/renderer.ts+19-2
......@@ -12,6 +12,11 @@ export interface GhosttyCellMetrics {
1212 readonly baseline: number;
1313}
1414
15export interface GhosttyCellRange {
16 readonly start: { readonly x: number; readonly y: number };
17 readonly end: { readonly x: number; readonly y: number };
18}
19
1520const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";
1621
1722function cssColor(color: GhosttyColor): string {
......@@ -98,6 +103,7 @@ export function renderGhosttySnapshot(options: {
98103 readonly previousCursorY?: number | null;
99104 readonly focused?: boolean;
100105 readonly selectionBackground?: string;
106 readonly hoveredLinkRange?: GhosttyCellRange | null;
101107 /** Vertical origin of row 0; defaults to the horizontal padding. */
102108 readonly originY?: number;
103109}): void {
......@@ -114,6 +120,7 @@ export function renderGhosttySnapshot(options: {
114120 } = options;
115121 const focused = options.focused ?? true;
116122 const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
123 const hoveredLinkRange = options.hoveredLinkRange ?? null;
117124 const originY = options.originY ?? padding;
118125 const rowsToDraw = forceFull
119126 ? Array.from({ length: snapshot.rows }, (_, index) => index)
......@@ -216,10 +223,20 @@ export function renderGhosttySnapshot(options: {
216223
217224 for (let column = 0; column < row.cells.length; column += 1) {
218225 const cell = row.cells[column];
219 if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue;
226 const hoveredLink =
227 hoveredLinkRange !== null &&
228 rowIndex >= hoveredLinkRange.start.y &&
229 rowIndex <= hoveredLinkRange.end.y &&
230 (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) &&
231 (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x);
232 if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) {
233 continue;
234 }
220235 context.fillStyle = cssColor(cell.foreground);
221236 const left = padding + column * metrics.width;
222 if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
237 if (cell.underline || hoveredLink) {
238 context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
239 }
223240 if (cell.strikethrough) {
224241 context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);
225242 }
apps/web/src/terminal/ghostty/surface.test.ts+47
......@@ -13,10 +13,13 @@ import {
1313 isTerminalPasteShortcut,
1414 shouldBlinkTerminalCursor,
1515 shouldReportTerminalMouse,
16 shouldShowTerminalLinkHover,
17 terminalGridCellAt,
1618 terminalScrollbarGeometry,
1719 terminalScrollbarOffsetAtPointer,
1820 terminalLinkAtColumn,
1921 terminalLinkAtPosition,
22 terminalLinkAtPositionWithRange,
2023 terminalContentOriginY,
2124 terminalFontFamily,
2225 fittedTerminalFontSize,
......@@ -56,6 +59,32 @@ describe("isTerminalAltGraphText", () => {
5659 });
5760});
5861
62describe("terminalGridCellAt", () => {
63 const options = {
64 bounds: { left: 100, top: 200 },
65 cols: 3,
66 rows: 2,
67 metrics: { width: 10, height: 20 },
68 padding: 4,
69 originY: 24,
70 };
71
72 it("maps points inside the rendered grid without clamping its padding", () => {
73 expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 224 })).toEqual({
74 x: 0,
75 y: 0,
76 });
77 expect(terminalGridCellAt({ ...options, clientX: 133, clientY: 263 })).toEqual({
78 x: 2,
79 y: 1,
80 });
81 expect(terminalGridCellAt({ ...options, clientX: 103, clientY: 224 })).toBeNull();
82 expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 223 })).toBeNull();
83 expect(terminalGridCellAt({ ...options, clientX: 134, clientY: 224 })).toBeNull();
84 expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 264 })).toBeNull();
85 });
86});
87
5988describe("shouldBlinkTerminalCursor", () => {
6089 const blinking = {
6190 focused: true,
......@@ -99,6 +128,10 @@ describe("terminalLinkAtColumn", () => {
99128 expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes");
100129 expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes");
101130 expect(terminalLinkAtColumn(row, 0)).toBeNull();
131 expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({
132 start: { x: 2, y: 0 },
133 end: { x: cells.length - 1, y: 0 },
134 });
102135 });
103136
104137 it("uses shared path matching and reconstructs soft-wrapped links", () => {
......@@ -119,6 +152,13 @@ describe("terminalLinkAtColumn", () => {
119152 expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference");
120153 expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file");
121154 expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts");
155 expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({
156 text: "https://example.com/reference",
157 range: {
158 start: { x: 0, y: 0 },
159 end: { x: 12, y: 1 },
160 },
161 });
122162 });
123163
124164 it("refuses links truncated at the viewport edges instead of mis-resolving", () => {
......@@ -245,6 +285,13 @@ describe("application mouse reporting", () => {
245285 it("maps browser buttons to Ghostty's button enum", () => {
246286 expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);
247287 });
288
289 it("only shows link hover during mouse tracking when the link modifier is held", () => {
290 expect(shouldShowTerminalLinkHover(false, false)).toBe(true);
291 expect(shouldShowTerminalLinkHover(false, true)).toBe(true);
292 expect(shouldShowTerminalLinkHover(true, false)).toBe(false);
293 expect(shouldShowTerminalLinkHover(true, true)).toBe(true);
294 });
248295});
249296
250297describe("terminal font resolution", () => {
apps/web/src/terminal/ghostty/surface.ts+187-10
......@@ -10,6 +10,7 @@ import {
1010 measureGhosttyCell,
1111 renderGhosttySnapshot,
1212 terminalGridSize,
13 type GhosttyCellRange,
1314 type GhosttyCellMetrics,
1415} from "./renderer";
1516import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
......@@ -205,6 +206,28 @@ export function terminalScrollbarOffsetAtPointer(
205206 return Math.round((thumbTop / travel) * geometry.maxOffset);
206207}
207208
209export function terminalGridCellAt(options: {
210 bounds: { left: number; top: number };
211 clientX: number;
212 clientY: number;
213 cols: number;
214 rows: number;
215 metrics: Pick<GhosttyCellMetrics, "width" | "height">;
216 padding: number;
217 originY: number;
218}): { x: number; y: number } | null {
219 const { bounds, clientX, clientY, cols, rows, metrics, padding, originY } = options;
220 const gridX = clientX - bounds.left - padding;
221 const gridY = clientY - bounds.top - originY;
222 if (gridX < 0 || gridY < 0 || gridX >= cols * metrics.width || gridY >= rows * metrics.height) {
223 return null;
224 }
225 return {
226 x: Math.floor(gridX / metrics.width),
227 y: Math.floor(gridY / metrics.height),
228 };
229}
230
208231function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string {
209232 const text = row.cells.map((cell) => cell.text || " ").join("");
210233 return trimRight ? text.trimEnd() : text;
......@@ -223,6 +246,27 @@ export function terminalLinkAtPosition(
223246 rowIndex: number,
224247 column: number,
225248): string | null {
249 return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null;
250}
251
252export interface TerminalLinkWithRange {
253 readonly text: string;
254 readonly range: GhosttyCellRange;
255}
256
257function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number {
258 for (let column = 0; column < row.cells.length; column += 1) {
259 const nextOffset = terminalColumnOffset(row, column + 1);
260 if (offset < nextOffset) return column;
261 }
262 return Math.max(0, row.cells.length - 1);
263}
264
265export function terminalLinkAtPositionWithRange(
266 rows: GhosttySnapshot["rowData"],
267 rowIndex: number,
268 column: number,
269): TerminalLinkWithRange | null {
226270 const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => {
227271 const row = rows[index];
228272 if (!row) return null;
......@@ -251,7 +295,28 @@ export function terminalLinkAtPosition(
251295 if (offset >= match.start && offset < match.end) {
252296 // A truncated tail must not activate as a complete link.
253297 if (match.end === wrappedLine.text.length && continuesBelowViewport) return null;
254 return match.text;
298 const startSegment = wrappedLine.segments.find(
299 (value) => match.start >= value.startIndex && match.start < value.endIndex,
300 );
301 const endSegment = wrappedLine.segments.find(
302 (value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex,
303 );
304 const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined;
305 const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined;
306 if (!startSegment || !endSegment || !startRow || !endRow) return null;
307 return {
308 text: match.text,
309 range: {
310 start: {
311 x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex),
312 y: startSegment.bufferLineNumber - 1,
313 },
314 end: {
315 x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex),
316 y: endSegment.bufferLineNumber - 1,
317 },
318 },
319 };
255320 }
256321 }
257322 return null;
......@@ -338,6 +403,13 @@ export function isTerminalLinkPointerGesture(
338403 : event.ctrlKey && !event.metaKey;
339404}
340405
406export function shouldShowTerminalLinkHover(
407 mouseTracking: boolean,
408 linkModifierActive: boolean,
409): boolean {
410 return !mouseTracking || linkModifierActive;
411}
412
341413export function ghosttyMouseButton(button: number): number | null {
342414 switch (button) {
343415 case 0:
......@@ -444,6 +516,9 @@ export class GhosttyTerminalSurface {
444516 private mouseReportingPointerId: number | null = null;
445517 private mouseReportingButton: number | null = null;
446518 private linkActivationPointerId: number | null = null;
519 private hoveredLink: TerminalLinkWithRange | null = null;
520 private hoverPointer: { x: number; y: number } | null = null;
521 private linkModifierActive = false;
447522 private selectionClickSequence: TerminalSelectionClickSequence | null = null;
448523 private selectionMoved = false;
449524 private composing = false;
......@@ -815,6 +890,7 @@ export class GhosttyTerminalSurface {
815890 }
816891
817892 private readonly onKeyDown = (event: KeyboardEvent) => {
893 this.updateLinkModifier(event);
818894 // Presses handled outside the terminal must also swallow their release:
819895 // beforeKey runs side effects (keybindings, navigation sends), so it cannot
820896 // be consulted again on keyup, and Kitty report-event-types sessions would
......@@ -866,6 +942,7 @@ export class GhosttyTerminalSurface {
866942 };
867943
868944 private readonly onKeyUp = (event: KeyboardEvent) => {
945 this.updateLinkModifier(event);
869946 if (this.suppressedKeyCodes.delete(event.code)) return;
870947 if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {
871948 return;
......@@ -887,6 +964,8 @@ export class GhosttyTerminalSurface {
887964
888965 private readonly onBlur = () => {
889966 this.focused = false;
967 this.linkModifierActive = false;
968 this.refreshHoveredLink();
890969 // Suppressions survive blur deliberately: a shortcut that moves focus (for
891970 // example terminal-toggle) must still swallow its own keyup if focus comes
892971 // back before release. Stale entries are harmless — an encoding keydown
......@@ -968,6 +1047,7 @@ export class GhosttyTerminalSurface {
9681047 if (button === null) return;
9691048 event.preventDefault();
9701049 event.stopPropagation();
1050 this.clearHoveredLink("default");
9711051 this.mouseReportingPointerId = event.pointerId;
9721052 this.mouseReportingButton = button;
9731053 this.sendMouse("press", button, event);
......@@ -982,6 +1062,7 @@ export class GhosttyTerminalSurface {
9821062 this.canvas.setPointerCapture(event.pointerId);
9831063 return;
9841064 }
1065 this.clearHoveredLink();
9851066 const cell = this.cellAt(event.clientX, event.clientY);
9861067 this.selectionMoved = false;
9871068 this.selectionClickSequence = advanceTerminalSelectionClickSequence(
......@@ -1029,6 +1110,11 @@ export class GhosttyTerminalSurface {
10291110 shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event)
10301111 ) {
10311112 event.preventDefault();
1113 this.hoverPointer = { x: event.clientX, y: event.clientY };
1114 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1115 // A drag whose press was already sent to the terminal application cannot
1116 // turn into link activation midway through, so link feedback would lie.
1117 this.setHoveredLink(null);
10321118 this.canvas.style.cursor = "default";
10331119 this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);
10341120 return;
......@@ -1037,6 +1123,7 @@ export class GhosttyTerminalSurface {
10371123 this.updateHoverCursor(event);
10381124 return;
10391125 }
1126 this.clearHoveredLink();
10401127 this.selectionPointer = { x: event.clientX, y: event.clientY };
10411128 const bounds = this.canvas.getBoundingClientRect();
10421129 this.setSelectionAutoscroll(
......@@ -1097,10 +1184,50 @@ export class GhosttyTerminalSurface {
10971184 }
10981185
10991186 private updateHoverCursor(event: PointerEvent): void {
1100 const overLink =
1101 isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null;
1102 const cursor = overLink ? "pointer" : "";
1103 if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor;
1187 this.hoverPointer = { x: event.clientX, y: event.clientY };
1188 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1189 this.refreshHoveredLink();
1190 }
1191
1192 private updateLinkModifier(event: Pick<KeyboardEvent, "ctrlKey" | "metaKey">): void {
1193 const active = isTerminalLinkPointerGesture(event);
1194 if (active === this.linkModifierActive) return;
1195 this.linkModifierActive = active;
1196 this.refreshHoveredLink();
1197 }
1198
1199 private readonly onPointerLeave = () => {
1200 this.clearHoveredLink();
1201 };
1202
1203 private clearHoveredLink(cursor = ""): void {
1204 this.hoverPointer = null;
1205 this.setHoveredLink(null);
1206 this.canvas.style.cursor = cursor;
1207 }
1208
1209 private refreshHoveredLink(): void {
1210 const pointer = this.hoverPointer;
1211 const link =
1212 pointer && shouldShowTerminalLinkHover(this.core.isMouseTracking(), this.linkModifierActive)
1213 ? this.linkAt(pointer.x, pointer.y)
1214 : null;
1215 this.setHoveredLink(link);
1216 }
1217
1218 private setHoveredLink(link: TerminalLinkWithRange | null): void {
1219 const previous = this.hoveredLink;
1220 const unchanged =
1221 previous?.text === link?.text &&
1222 previous?.range.start.x === link?.range.start.x &&
1223 previous?.range.start.y === link?.range.start.y &&
1224 previous?.range.end.x === link?.range.end.x &&
1225 previous?.range.end.y === link?.range.end.y;
1226 this.canvas.style.cursor = link ? "pointer" : "";
1227 if (unchanged) return;
1228 this.hoveredLink = link;
1229 this.forceFullRender = true;
1230 this.requestRender();
11041231 }
11051232
11061233 private readonly onPointerUp = (event: PointerEvent) => {
......@@ -1114,7 +1241,7 @@ export class GhosttyTerminalSurface {
11141241 }
11151242 if (event.type !== "pointercancel") {
11161243 const link = this.linkAt(event.clientX, event.clientY);
1117 if (link) this.options.onLinkActivate(link, event);
1244 if (link) this.options.onLinkActivate(link.text, event);
11181245 }
11191246 return;
11201247 }
......@@ -1127,6 +1254,13 @@ export class GhosttyTerminalSurface {
11271254 if (this.canvas.hasPointerCapture(event.pointerId)) {
11281255 this.canvas.releasePointerCapture(event.pointerId);
11291256 }
1257 if (event.type === "pointercancel") {
1258 this.clearHoveredLink();
1259 } else {
1260 this.hoverPointer = { x: event.clientX, y: event.clientY };
1261 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1262 this.refreshHoveredLink();
1263 }
11301264 return;
11311265 }
11321266 if (this.canvas.hasPointerCapture(event.pointerId)) {
......@@ -1253,6 +1387,7 @@ export class GhosttyTerminalSurface {
12531387 this.input.addEventListener("compositionend", this.onCompositionEnd);
12541388 this.canvas.addEventListener("pointerdown", this.onPointerDown);
12551389 this.canvas.addEventListener("pointermove", this.onPointerMove);
1390 this.canvas.addEventListener("pointerleave", this.onPointerLeave);
12561391 this.canvas.addEventListener("pointerup", this.onPointerUp);
12571392 this.canvas.addEventListener("pointercancel", this.onPointerUp);
12581393 this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
......@@ -1276,6 +1411,7 @@ export class GhosttyTerminalSurface {
12761411 this.input.removeEventListener("compositionend", this.onCompositionEnd);
12771412 this.canvas.removeEventListener("pointerdown", this.onPointerDown);
12781413 this.canvas.removeEventListener("pointermove", this.onPointerMove);
1414 this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
12791415 this.canvas.removeEventListener("pointerup", this.onPointerUp);
12801416 this.canvas.removeEventListener("pointercancel", this.onPointerUp);
12811417 this.canvas.removeEventListener("wheel", this.onWheel);
......@@ -1378,6 +1514,7 @@ export class GhosttyTerminalSurface {
13781514 this.originY = nextOriginY;
13791515 this.forceFullRender = true;
13801516 }
1517 this.refreshHoveredLink();
13811518 renderGhosttySnapshot({
13821519 context: this.context,
13831520 snapshot: this.snapshot,
......@@ -1390,6 +1527,7 @@ export class GhosttyTerminalSurface {
13901527 cursorOn: this.cursorOn,
13911528 previousCursorY: this.renderedCursorY,
13921529 focused: this.focused,
1530 hoveredLinkRange: this.hoveredLink?.range ?? null,
13931531 ...(this.theme.selectionBackground !== undefined
13941532 ? { selectionBackground: this.theme.selectionBackground }
13951533 : {}),
......@@ -1466,12 +1604,51 @@ export class GhosttyTerminalSurface {
14661604 };
14671605 }
14681606
1469 private linkAt(clientX: number, clientY: number): string | null {
1607 private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {
14701608 if (!this.snapshot) return null;
1471 const cell = this.cellAt(clientX, clientY);
1609 const cell = terminalGridCellAt({
1610 bounds: this.canvas.getBoundingClientRect(),
1611 clientX,
1612 clientY,
1613 cols: this.cols,
1614 rows: this.rows,
1615 metrics: this.metrics,
1616 padding: CONTENT_PADDING,
1617 originY: this.originY,
1618 });
1619 if (!cell) return null;
14721620 const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y);
1473 if (explicitHyperlink) return explicitHyperlink;
1474 return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x);
1621 if (explicitHyperlink) {
1622 const start = { ...cell };
1623 const end = { ...cell };
1624 while (true) {
1625 const previous =
1626 start.x > 0
1627 ? { x: start.x - 1, y: start.y }
1628 : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation
1629 ? { x: this.cols - 1, y: start.y - 1 }
1630 : null;
1631 if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break;
1632 start.x = previous.x;
1633 start.y = previous.y;
1634 }
1635 while (true) {
1636 const next =
1637 end.x + 1 < this.cols
1638 ? { x: end.x + 1, y: end.y }
1639 : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext
1640 ? { x: 0, y: end.y + 1 }
1641 : null;
1642 if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break;
1643 end.x = next.x;
1644 end.y = next.y;
1645 }
1646 return {
1647 text: explicitHyperlink,
1648 range: { start, end },
1649 };
1650 }
1651 return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);
14751652 }
14761653
14771654 private sendMouse(