Skip to content

Commit 1f0a63a

Browse files
ardittih0x91b
andauthored
Make OSC 8 file:// terminal hyperlinks clickable (#1617)
* Make OSC 8 file:// terminal hyperlinks clickable ghostty underlines OSC 8 cells on hover at the renderer level, but the app's link provider dropped every non-http(s) URI, so file:// links from agents (Claude Code wraps printed paths via pathToFileURL when FORCE_HYPERLINK=1 — which dev3 itself sets) had hover affordance and a dead click. Accept file URIs with an empty/localhost host, convert them back to a local path (Windows drive form included, trailing :line:col carried out), and activate through the existing resolveTerminalPaths + activateTerminalPath flow so the allowed-roots policy and the file-open setting keep applying. * Open the OSC 8 link under the cursor, not the cached one ghostty-web keys its link cache by hyperlink id and gives every OSC 8 link on screen the same id, answering a hit-test from that cache before it scans the clicked row. Every link therefore activated whichever one was scanned first, so the hover tooltip named one file and the click opened another. Activation now re-resolves the cell under the pointer through shared cell math, and moves into activateOsc8Uri so the branch is testable: a file URI that cannot become a path no longer falls through to window.open, and percent-decoding can no longer re-introduce control characters into the path. The link suffix parser is bounded and the refusal message names both reasons a path can fail. --------- Co-authored-by: h0x91B <h0x91b@gmail.com>
1 parent 124bd81 commit 1f0a63a

13 files changed

Lines changed: 450 additions & 21 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Short: Terminal file links open the right file
2+
3+
Terminal file paths that agents wrap in OSC 8 file:// hyperlinks (Claude Code does) showed a hover underline but a dead click: the link provider accepted only http(s) URIs. file:// links now Cmd/Ctrl+Click open like plain path links — resolved on the backend against the allowed roots, then previewed or revealed per the "File path click action" setting. Clicking an OSC 8 link also opens the link actually under the cursor: every such link on screen shares one hyperlink id, so the terminal's own link cache used to answer every click with whichever link was hovered first, and the hover tooltip named one file while the click opened another.
4+
5+
Suggested by @arditti (h0x91b/dev-3.0#1617)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# OSC 8 activation re-resolves the cell under the cursor
2+
3+
## Context
4+
5+
Making OSC 8 `file://` hyperlinks clickable turned a wrong-link click from "opens
6+
the wrong URL in a browser" into "opens the wrong file off the disk", and it made
7+
the hover tooltip load-bearing: OSC 8 lets the visible label differ from the
8+
target, so the tooltip is the only thing on screen that names where a click will
9+
go. That only holds if the click and the tooltip agree.
10+
11+
## Investigation
12+
13+
They did not. In the running app, with four `file://` links on screen, hovering
14+
the first one and then clicking the fourth opened the first one's file — and
15+
reversing the hover order reversed the outcome. Reproduced identically with three
16+
`https` links, so it predates the file-URI work.
17+
18+
The mechanism is in `ghostty-web`'s `LinkDetector` (`dist/ghostty-web.js`,
19+
`cacheLink` / `getLinkAt`): it caches a link under the key `h<hyperlinkId>` when
20+
the range's first cell carries a hyperlink id, and it answers a hit-test from
21+
that cache **before** it scans the clicked row. ghostty assigns the same
22+
hyperlink id to every OSC 8 link on screen — including when the emitter sends
23+
distinct `id=` parameters — so one cache entry serves every link, and the first
24+
row scanned after a write owns all of them until the next write clears the cache.
25+
dev3's own provider is not involved: it resolves each row correctly, which is
26+
exactly why the tooltip and the click disagreed.
27+
28+
## Decision
29+
30+
`ILink.activate` no longer trusts the URI its closure captured. It asks
31+
`cellFromEvent` for the cell under the pointer and re-resolves the link there
32+
(`createOsc8LinkProvider` in `src/mainview/terminal-osc8-links.ts`), falling back
33+
to its own URI when the pointer is off the grid. The cell math is shared with the
34+
hover tooltip through `cellFromMouseEvent` (`src/mainview/terminal-cell-hit.ts`)
35+
so the two surfaces cannot drift apart again. Activation itself moved into
36+
`activateOsc8Uri` (`src/mainview/terminal-path-open.ts`) so the branch is
37+
testable without a terminal.
38+
39+
## Risks
40+
41+
The fallback keeps the stale URI when `cellFromEvent` returns nothing, so a click
42+
whose coordinates do not land on the grid still behaves as before. The
43+
re-resolution costs one row scan per click, which is what a hover already does on
44+
every frame. If a future ghostty-web assigns real per-link ids, this becomes
45+
redundant but stays correct.
46+
47+
## Alternatives considered
48+
49+
Patching the upstream cache key was rejected as it means carrying a fork of
50+
`ghostty-web` for a defect we can neutralise in three lines on our side. Not
51+
registering the provider's ranges at all (handling clicks on the canvas
52+
ourselves) would duplicate ghostty's hit-testing and lose its hover underline.

src/mainview/TerminalView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ import { createAnsiThemeFilter } from "./utils/ansi-theme-adapt";
4848
import { submitPastedText } from "./terminal-submit";
4949
import { createFilePathLinkProvider, type FilePathLinkProvider } from "./terminal-file-links";
5050
import { createOsc8Tracker, createOsc8LinkProvider } from "./terminal-osc8-links";
51+
import { cellFromMouseEvent } from "./terminal-cell-hit";
5152
import { installOsc8HoverTooltip, type Osc8HoverHandle } from "./terminal-osc8-hover";
5253
import { installFilePathUnderlines, type FilePathUnderlinesHandle } from "./terminal-link-underlines";
53-
import { activateTerminalPath } from "./terminal-path-open";
54+
import { activateTerminalPath, activateOsc8Uri } from "./terminal-path-open";
5455
import { isRemote } from "./utils/platform";
5556
import { paneHighlightRect, type PaneRectPct } from "./utils/paneHighlight";
5657
import TerminalSearchBar, { type TerminalSearchBarHandle } from "./components/TerminalSearchBar";
@@ -808,8 +809,9 @@ function TerminalView({ ptyUrl, taskId, projectId, onReady, onNativeStatus, onSe
808809
return term.buffer.active.getLine(y)?.isWrapped;
809810
},
810811
uriFor: osc8Tracker.uriFor,
812+
cellFromEvent: (event) => cellFromMouseEvent(term, event),
811813
onActivate: (uri) => {
812-
window.open(uri, "_blank", "noopener,noreferrer");
814+
void activateOsc8Uri(uri, { t: tRef.current, taskId, projectId });
813815
},
814816
});
815817
term.registerLinkProvider(osc8Provider);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect } from "vitest";
2+
import { cellFromMouseEvent } from "../terminal-cell-hit";
3+
import type { Terminal } from "ghostty-web";
4+
5+
function fakeTerm(opts: { bufferLength?: number; viewportY?: number } = {}): Terminal {
6+
const canvas = document.createElement("canvas");
7+
canvas.getBoundingClientRect = () => ({ left: 100, top: 50 }) as DOMRect;
8+
return {
9+
renderer: { charWidth: 10, charHeight: 20, getCanvas: () => canvas },
10+
buffer: { active: { length: opts.bufferLength ?? 24 } },
11+
rows: 24,
12+
cols: 80,
13+
viewportY: opts.viewportY ?? 0,
14+
} as unknown as Terminal;
15+
}
16+
17+
function at(term: Terminal, clientX: number, clientY: number) {
18+
return cellFromMouseEvent(term, new MouseEvent("click", { clientX, clientY }));
19+
}
20+
21+
describe("cellFromMouseEvent", () => {
22+
it("maps a point to the cell under it, relative to the canvas", () => {
23+
expect(at(fakeTerm(), 100, 50)).toEqual({ x: 0, y: 0, viewportRow: 0 });
24+
expect(at(fakeTerm(), 135, 91)).toEqual({ x: 3, y: 2, viewportRow: 2 });
25+
});
26+
27+
it("offsets by the scrollback so the row is an absolute buffer row", () => {
28+
// 100 rows in the buffer, 24 on screen, scrolled to the bottom.
29+
expect(at(fakeTerm({ bufferLength: 100 }), 100, 50)?.y).toBe(76);
30+
});
31+
32+
it("returns nothing outside the grid", () => {
33+
expect(at(fakeTerm(), 99, 50)).toBeUndefined();
34+
expect(at(fakeTerm(), 100, 49)).toBeUndefined();
35+
expect(at(fakeTerm(), 100 + 80 * 10, 50)).toBeUndefined();
36+
expect(at(fakeTerm(), 100, 50 + 24 * 20)).toBeUndefined();
37+
});
38+
39+
it("returns nothing before the renderer has measured a cell", () => {
40+
const term = { renderer: { charWidth: 0, charHeight: 0, getCanvas: () => null } } as unknown as Terminal;
41+
expect(at(term, 10, 10)).toBeUndefined();
42+
});
43+
});

src/mainview/__tests__/terminal-osc8-links.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
createOsc8Tracker,
44
createOsc8LinkProvider,
55
safeHttpUri,
6+
safeFileUri,
7+
fileUriToLocalPath,
68
type HyperlinkLine,
79
} from "../terminal-osc8-links";
810
import type { ILink } from "ghostty-web";
@@ -161,6 +163,40 @@ describe("createOsc8LinkProvider", () => {
161163
expect(onActivate).toHaveBeenCalledWith("https://x.dev", expect.anything());
162164
});
163165

166+
it("activates the link under the cursor, not the ILink it was handed", () => {
167+
// ghostty-web keys its link cache by hyperlink id and gives every OSC 8
168+
// link on screen the same id, so a click in row 1 arrives carrying the
169+
// ILink built for row 0. The cell under the cursor has to win, or the
170+
// hover tooltip promises one target and the click opens another.
171+
const rows: Record<number, HyperlinkLine> = {
172+
0: lineOf("ONE", [1, 1, 1]),
173+
1: lineOf("TWO", [1, 1, 1]),
174+
};
175+
const uriFor = (label: string) =>
176+
label === "ONE" ? "file:///tmp/one.txt" : label === "TWO" ? "file:///tmp/two.txt" : undefined;
177+
const onActivate = vi.fn();
178+
const provider = createOsc8LinkProvider({
179+
getLine: (row) => rows[row],
180+
isRowWrapped: () => false,
181+
uriFor,
182+
cellFromEvent: () => ({ y: 1, x: 0 }),
183+
onActivate,
184+
});
185+
let links: ILink[] = [];
186+
provider.provideLinks(0, (found) => {
187+
links = found ?? [];
188+
});
189+
expect(links[0].text).toBe("file:///tmp/one.txt");
190+
links[0].activate(new MouseEvent("click", { metaKey: true }));
191+
expect(onActivate).toHaveBeenCalledWith("file:///tmp/two.txt", expect.anything());
192+
});
193+
194+
it("keeps its own URI when the click lands on no link at all", () => {
195+
const { links, onActivate } = collectLinks({ 0: lineOf("#7", [1, 1]) }, () => "https://x.dev", 0);
196+
links[0].activate(new MouseEvent("click", { metaKey: true }));
197+
expect(onActivate).toHaveBeenCalledWith("https://x.dev", expect.anything());
198+
});
199+
164200
it("falls back to the label itself when it is a safe URI", () => {
165201
const text = "https://self.dev";
166202
const { links } = collectLinks({ 0: lineOf(text, text.split("").map(() => 2)) }, () => undefined, 0);
@@ -169,16 +205,28 @@ describe("createOsc8LinkProvider", () => {
169205
});
170206

171207
it("drops unknown labels and unsafe URIs", () => {
172-
const text = "#9 file";
208+
const text = "#9 evil";
173209
const ids = [1, 1, 0, 3, 3, 3, 3];
174210
const { links } = collectLinks(
175211
{ 0: lineOf(text, ids) },
176-
(l) => (l === "file" ? "file:///etc/passwd" : undefined),
212+
(l) => (l === "evil" ? "javascript:alert(1)" : undefined),
177213
0,
178214
);
179215
expect(links).toHaveLength(0);
180216
});
181217

218+
it("produces a link for a file:// URI (agents wrap printed paths in them)", () => {
219+
const text = "read a.png ok";
220+
const ids = [0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 0, 0, 0];
221+
const { links } = collectLinks(
222+
{ 0: lineOf(text, ids) },
223+
(l) => (l === "a.png" ? "file:///tmp/mock%20dir/a.png" : undefined),
224+
0,
225+
);
226+
expect(links).toHaveLength(1);
227+
expect(links[0].text).toBe("file:///tmp/mock%20dir/a.png");
228+
});
229+
182230
it("stitches a wrapped link across rows to resolve the full label", () => {
183231
// Row 3 ends with "#12" (id 4), row 4 starts with "34" (id 4).
184232
const top = "text #12";
@@ -291,3 +339,57 @@ describe("safeHttpUri", () => {
291339
expect(safeHttpUri("https://")).toBeUndefined();
292340
});
293341
});
342+
343+
describe("safeFileUri", () => {
344+
it("accepts local file URIs, empty or localhost host", () => {
345+
expect(safeFileUri("file:///tmp/a.png")).toBe("file:///tmp/a.png");
346+
expect(safeFileUri("file://localhost/tmp/a.png")).toBe("file://localhost/tmp/a.png");
347+
});
348+
349+
it("rejects remote hosts, other schemes, and control characters", () => {
350+
expect(safeFileUri("file://evil.example/share/x")).toBeUndefined();
351+
expect(safeFileUri("https://a.dev")).toBeUndefined();
352+
expect(safeFileUri("file:///tmp/a b.png")).toBeUndefined();
353+
expect(safeFileUri("file:///tmp/a\nb.png")).toBeUndefined();
354+
});
355+
});
356+
357+
describe("fileUriToLocalPath", () => {
358+
it("converts a pathToFileURL-style URI back to a path", () => {
359+
expect(fileUriToLocalPath("file:///tmp/mock-cc-global.png")).toEqual({ path: "/tmp/mock-cc-global.png" });
360+
});
361+
362+
it("percent-decodes the path", () => {
363+
expect(fileUriToLocalPath("file:///tmp/mock%20dir/a.png")).toEqual({ path: "/tmp/mock dir/a.png" });
364+
});
365+
366+
it("strips the leading slash of a Windows drive path", () => {
367+
expect(fileUriToLocalPath("file:///C:/dir/file.ts")).toEqual({ path: "C:/dir/file.ts" });
368+
});
369+
370+
it("carries a trailing :line[:col] suffix out separately", () => {
371+
expect(fileUriToLocalPath("file:///a/b.ts:12")).toEqual({ path: "/a/b.ts", line: 12 });
372+
expect(fileUriToLocalPath("file:///a/b.ts:12:5")).toEqual({ path: "/a/b.ts", line: 12 });
373+
});
374+
375+
it("rejects a path whose decoding re-introduces a control character", () => {
376+
// safeFileUri sees the encoded form and passes it; the NUL and the
377+
// newline only exist after decodeURIComponent. A space is legitimate.
378+
expect(fileUriToLocalPath("file:///tmp/a%00b")).toBeUndefined();
379+
expect(fileUriToLocalPath("file:///tmp/a%0Ab")).toBeUndefined();
380+
expect(fileUriToLocalPath("file:///tmp/a%20b")).toEqual({ path: "/tmp/a b" });
381+
});
382+
383+
it("leaves an absurd line suffix as part of the path instead of parsing it", () => {
384+
expect(fileUriToLocalPath("file:///a/b.ts:99999999999999999999")).toEqual({
385+
path: "/a/b.ts:99999999999999999999",
386+
});
387+
expect(fileUriToLocalPath("file:///a/b.ts:999999999")).toEqual({ path: "/a/b.ts", line: 999_999_999 });
388+
});
389+
390+
it("rejects what safeFileUri rejects, and undecodable paths", () => {
391+
expect(fileUriToLocalPath("https://a.dev/x")).toBeUndefined();
392+
expect(fileUriToLocalPath("file://evil.example/x")).toBeUndefined();
393+
expect(fileUriToLocalPath("file:///tmp/%zz")).toBeUndefined();
394+
});
395+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { activateOsc8Uri, OPEN_FILE_PREVIEW_EVENT, type OpenFilePreviewDetail } from "../terminal-path-open";
3+
import { api } from "../rpc";
4+
import { toast } from "../toast";
5+
6+
vi.mock("../rpc", () => ({
7+
isElectrobun: false,
8+
api: {
9+
request: {
10+
resolveTerminalPaths: vi.fn(),
11+
getGlobalSettings: vi.fn(),
12+
openTerminalPath: vi.fn(),
13+
},
14+
},
15+
}));
16+
17+
vi.mock("../toast", () => ({
18+
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() },
19+
}));
20+
21+
const resolveTerminalPaths = api.request.resolveTerminalPaths as unknown as ReturnType<typeof vi.fn>;
22+
const t = ((key: string, vars?: Record<string, string>) =>
23+
`${key}:${JSON.stringify(vars ?? {})}`) as unknown as Parameters<typeof activateOsc8Uri>[1]["t"];
24+
25+
function previewed(): Promise<OpenFilePreviewDetail> {
26+
return new Promise((resolve) => {
27+
window.addEventListener(
28+
OPEN_FILE_PREVIEW_EVENT,
29+
(event) => resolve((event as CustomEvent<OpenFilePreviewDetail>).detail),
30+
{ once: true },
31+
);
32+
});
33+
}
34+
35+
describe("activateOsc8Uri", () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
});
39+
40+
it("opens a resolved file URI through the backend gate, carrying the line", async () => {
41+
resolveTerminalPaths.mockResolvedValue({ resolved: { "/repo/src/a.ts": { path: "/repo/src/a.ts", kind: "file" } } });
42+
const detail = previewed();
43+
await activateOsc8Uri("file:///repo/src/a.ts:12:5", { t, taskId: "task-1", projectId: "proj-1" });
44+
expect(resolveTerminalPaths).toHaveBeenCalledWith({ taskId: "task-1", projectId: "proj-1", paths: ["/repo/src/a.ts"] });
45+
expect(await detail).toEqual({ path: "/repo/src/a.ts", line: 12, taskId: "task-1" });
46+
});
47+
48+
it("says so when the backend refuses the path, and opens nothing", async () => {
49+
resolveTerminalPaths.mockResolvedValue({ resolved: { "/etc/passwd": null } });
50+
const open = vi.spyOn(window, "open").mockReturnValue(null);
51+
await activateOsc8Uri("file:///etc/passwd", { t });
52+
expect(open).not.toHaveBeenCalled();
53+
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("terminal.fileLinkNotFound"), expect.anything());
54+
open.mockRestore();
55+
});
56+
57+
it("never hands a file URI to window.open when it cannot become a path", async () => {
58+
const open = vi.spyOn(window, "open").mockReturnValue(null);
59+
await activateOsc8Uri("file:///tmp/%zz", { t });
60+
expect(open).not.toHaveBeenCalled();
61+
expect(resolveTerminalPaths).not.toHaveBeenCalled();
62+
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("terminal.fileLinkUnreadable"), expect.anything());
63+
open.mockRestore();
64+
});
65+
66+
it("opens a non-file URI externally", async () => {
67+
const open = vi.spyOn(window, "open").mockReturnValue(null);
68+
await activateOsc8Uri("https://example.com/pr/1", { t });
69+
expect(open).toHaveBeenCalledWith("https://example.com/pr/1", "_blank", "noopener,noreferrer");
70+
expect(resolveTerminalPaths).not.toHaveBeenCalled();
71+
open.mockRestore();
72+
});
73+
74+
it("reports a failed resolve instead of throwing into the click handler", async () => {
75+
resolveTerminalPaths.mockRejectedValue(new Error("rpc down"));
76+
await activateOsc8Uri("file:///repo/a.ts", { t });
77+
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("terminal.pathLinkOpenFailed"), expect.anything());
78+
});
79+
});

src/mainview/i18n/translations/en/terminal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ const terminal = {
248248
// File path links in terminal output (Cmd/Ctrl+Click)
249249
"terminal.pathLinkFolderBrowser": "Folders can't be opened from the browser — use the desktop app.",
250250
"terminal.pathLinkOpenFailed": "Couldn't open path: {error}",
251+
"terminal.fileLinkNotFound": "Can't open {path} — it's missing, or outside the allowed folders.",
252+
"terminal.fileLinkUnreadable": "That link isn't a readable file path: {uri}",
251253
"terminal.filePreviewLoading": "Loading preview…",
252254
"terminal.filePreviewNotFound": "File not found — it may have been moved or deleted.",
253255
"terminal.filePreviewBinary": "Binary file ({size}) — no preview available.",

src/mainview/i18n/translations/es/terminal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ const terminal = {
248248
// File path links in terminal output (Cmd/Ctrl+Click)
249249
"terminal.pathLinkFolderBrowser": "Las carpetas no se pueden abrir desde el navegador — usa la aplicación de escritorio.",
250250
"terminal.pathLinkOpenFailed": "No se pudo abrir la ruta: {error}",
251+
"terminal.fileLinkNotFound": "No se puede abrir {path}: no existe o está fuera de las carpetas permitidas.",
252+
"terminal.fileLinkUnreadable": "Ese enlace no es una ruta de archivo legible: {uri}",
251253
"terminal.filePreviewLoading": "Cargando vista previa…",
252254
"terminal.filePreviewNotFound": "Archivo no encontrado — puede que se haya movido o eliminado.",
253255
"terminal.filePreviewBinary": "Archivo binario ({size}) — vista previa no disponible.",

src/mainview/i18n/translations/ru/terminal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ const terminal = {
258258
// File path links in terminal output (Cmd/Ctrl+Click)
259259
"terminal.pathLinkFolderBrowser": "Папки нельзя открыть из браузера — используйте настольное приложение.",
260260
"terminal.pathLinkOpenFailed": "Не удалось открыть путь: {error}",
261+
"terminal.fileLinkNotFound": "Не удалось открыть {path} — файла нет или он вне разрешённых папок.",
262+
"terminal.fileLinkUnreadable": "Эта ссылка не похожа на путь к файлу: {uri}",
261263
"terminal.filePreviewLoading": "Загрузка предпросмотра…",
262264
"terminal.filePreviewNotFound": "Файл не найден — возможно, его переместили или удалили.",
263265
"terminal.filePreviewBinary": "Двоичный файл ({size}) — предпросмотр недоступен.",

src/mainview/terminal-cell-hit.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Terminal } from "ghostty-web";
2+
import { viewportRowToAbsolute } from "./terminal-link-underlines";
3+
4+
/** A terminal cell under the pointer: absolute buffer row, viewport column. */
5+
export interface TerminalCell {
6+
y: number;
7+
x: number;
8+
/** The same row counted from the top of the viewport, for positioning. */
9+
viewportRow: number;
10+
}
11+
12+
/**
13+
* The cell a mouse event points at, or undefined when it lands outside the
14+
* grid. Same math ghostty-web's own click handler uses, shared by the OSC 8
15+
* hover tooltip and the OSC 8 click so both read the same cell.
16+
*/
17+
export function cellFromMouseEvent(term: Terminal, event: MouseEvent): TerminalCell | undefined {
18+
const renderer = term.renderer;
19+
const canvas = renderer?.getCanvas();
20+
if (!renderer || !canvas || !renderer.charWidth || !renderer.charHeight) return undefined;
21+
const rect = canvas.getBoundingClientRect();
22+
const x = Math.floor((event.clientX - rect.left) / renderer.charWidth);
23+
const viewportRow = Math.floor((event.clientY - rect.top) / renderer.charHeight);
24+
if (x < 0 || x >= term.cols || viewportRow < 0 || viewportRow >= term.rows) return undefined;
25+
const scrollback = Math.max(0, term.buffer.active.length - term.rows);
26+
return { y: viewportRowToAbsolute(viewportRow, term.viewportY, scrollback), x, viewportRow };
27+
}

0 commit comments

Comments
 (0)