Skip to content

feat(terminal): cmd-click bare file paths to open them - #73

Open
jiweiyuan wants to merge 6 commits into
mainfrom
feat/clickable-file-paths
Open

feat(terminal): cmd-click bare file paths to open them#73
jiweiyuan wants to merge 6 commits into
mainfrom
feat/clickable-file-paths

Conversation

@jiweiyuan

@jiweiyuan jiweiyuan commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Agent CLIs print bare file references — BranchModel.swift, src/foo.ts:42, App.swift:88:15. They are plain text: not OSC 8 hyperlinks, not a URL scheme, so libghostty's link detector doesn't light most of them up, and upstream has declined row/column parsing. TerminalLinkOpening and the cmd-click interceptor already route everything libghostty does detect; this PR is the fallback for everything it doesn't.

VS Code makes the same paths clickable with its own terminalLinkParsing layer. What keeps that layer stable rather than a false-positive machine is not the regex — it is that every candidate is validated against the filesystem before it becomes a link. This mirrors that shape.

What it does

On a cmd-click that libghostty did not resolve to a link, reconstruct the token under the pointer from the grid text, peel a trailing :line[:col], resolve it against the surface's working directory, and open it in the read-only preview only if it resolves to a real file on disk. Anything that doesn't validate is silently dropped, so an imprecise click column or a stray word never opens the wrong thing — the click just falls through to the terminal as before.

  • TerminalPathScanner — pure, tested token → path/line parsing plus the filesystem gate. Handles :line / :line:col suffixes, wrapping punctuation ((file.swift:10).), git-diff a/ b/ prefixes, shell backslash escapes (my\ file.ts), and paths containing spaces (adjacent tokens are rejoined, but only into spans that cover the clicked column, so widening can never wander onto a file the user never pointed at).
  • TermioStore.openBarePathUnderCommandClick — click → cell → row → open, reusing the wrapper's own point→cell convention and finding the surface by geometry rather than hitTest (ghostty disables link detection under mouse reporting, so the fallback must not depend on the z-order hitTest gives).
  • Resolution is tried against an ordered list of roots, most-specific first: the live kernel cwd (PROC_PIDVNODEPATHINFO, which tracks a plain cd even when the shell never emits OSC 7), then the session's worktree and project root. The OSC 7 cwd alone is stale too often — it still reads ~ after a cd, which is exactly when package.json should still open.

Device correctness

Anything that reads local disk may only ever run for a session whose filesystem is this Mac's. The original branch skipped sshHost sessions; that was written before termiod, and a termiod remote session runs the same .inMemory backend as a local shell — so a path printed by a shell on a VPS would have been resolved against this Mac, where src/main.rs also exists.

It now asks the device: isOnThisMac, over the termiodRemoteHost ?? sshHost rule DeviceContext owns. That rule was spelled out by hand in three places, which is how one copy came to know about only one of the two ways of being elsewhere; it now has a single home (deviceAlias(of:)) and the other call sites route through it. A surface that can't be identified is declined rather than assumed local, and the selected session is no longer a resolution root — on a multi-device window it can belong to another machine entirely.

The gate now sits at both places that read local disk, because they are two resolution points, not one. The fallback scanner validates candidates itself and never calls openTerminalLink, so its gate stays. openTerminalLink is where every link libghostty did detect lands, and its no-scheme branch resolved a path against local disk after only a fileExists check — no device check at all. It now takes the session the link was clicked in and asks the same question before touching the filesystem; the hovered-link path finds its surface by geometry the way the fallback does, and an unnameable surface is declined. Real schemes (http, https, mailto, file:) are untouched — a handler's business, not this Mac's disk. Relative links from a surface also resolve against that session's own workspace now, instead of the selected session's.

That hole is live at our pin, not theoretical. The URL regex compiled into libghostty-swift 1.0.16 (ghostty 9d8fbd1) has three branches, not one: scheme URLs, rooted/dot-relative paths, and bare relative paths (src/main.rs). MOUSE_OVER_LINK reports the matched text verbatim, and an OSC 8 target is whatever string a program wrote — so a scheme-less path from a remote session already reached that branch.

Measured against the shipped regex, ghostty detects src/main.rs, Sources/App/App.swift:88:15 (suffix included in the match), ./package.json, /etc/hosts, a/Sources/App.swift. It does not detect a filename with no slash (BranchModel.swift, package.json, config.yaml:3) or any path containing a space, and it refreshes hover links only while mouse reporting is off — i.e. never inside an agent TUI, which is this PR's main case. Two follow-ups fall out of that, deliberately not done here: openTerminalLink does not peel a :line[:col] suffix, so in a plain shell a detected src/foo.ts:42 is consumed by the hover path and opens nothing (the fallback never sees it); and the hover path consumes the click even when it opens nothing.

Verification

swift test green (393 tests, 0 failures), including 16 TerminalPathScannerTests and 6 TerminalLinkDeviceGateTests that drive the resolution point directly (local session opens; termiod and ssh sessions open nothing; an absolute path from a remote session is declined too; an unidentified or unknown surface is declined). scripts/check-strings.sh clean; adds no user-facing strings. TERMIO_CHANNEL=dev ./scripts/build-app.sh builds and signs.

The click itself is a GUI interaction and is not covered by any of that — the click→cell mapping and the click→surface lookup are AppKit geometry over a live surface, and a green build proves nothing about either. To check by hand, in a dev build:

  1. In a plain shell, ls a repo and cmd-click a printed filename → opens in the read-only preview.
  2. cd into a subdirectory without OSC 7 reporting, cmd-click a bare package.json → still opens (this is the kernel-cwd root).
  3. In a Claude Code / agent TUI (mouse-capturing), cmd-click a path it printed with a :42 suffix → opens at that line.
  4. Cmd-click a path containing a space, and one wrapped in punctuation like (Sources/App.swift:88). → opens.
  5. Cmd-click an ordinary word that is not a file → nothing happens, and the click reaches the TUI normally.
  6. Cmd-click a real hyperlink / URL → unchanged, still routed by the existing hovered-URL path.
  7. The device gate, fallback path: in a session on a remote device (termiod or ssh), cmd-click a bare filename with no slash (README.md, printed by ls) that also exists in the Mac's current project → nothing should open.
  8. The device gate, hovered-link path: in that same remote session, hover with ⌘ held over a path ghostty does detect — src/main.rs, or printf '\e]8;;src/main.rs\e\\click\e]8;;\e\\\n' for the OSC 8 spelling — and cmd-click it → nothing should open. Doing the same in a local session on the same repo → opens. This is the regression the second gate fixes; before it, the Mac's file of that name opened silently.

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
landing Ready Ready Preview Aug 17, 2026 6:02pm

Request Review

Agent CLIs print bare file references like `BranchModel.swift`,
`src/foo.ts:42`, or `App.swift:88:15`. These are plain text — not OSC 8
hyperlinks and not a URL scheme — so libghostty's link detector never
lights them up (its regex only matches real schemes, and Ghostty upstream
has declined to add row/column parsing). VS Code makes the same paths
clickable with its own terminalLinkParsing layer; what makes that layer
stable rather than a false-positive machine is filesystem validation, not
the regex.

Mirror that shape: on a cmd-click that libghostty didn't resolve to a
link, reconstruct the token under the click from the grid text
(readViewportText), peel a trailing `:line[:col]`, resolve it against the
surface's working directory, and open it in the read-only preview only if
it resolves to a real file on disk. An imprecise click column or a stray
word is silently dropped — every candidate on the row is validated and the
nearest hit wins, so column precision is never load-bearing.

- TerminalPathScanner: pure, tested token → path/line parsing + fs gate
- TermioStore.openBarePathUnderCommandClick: click → cell → row → open,
  reusing the wrapper's own point→cell convention and the
  delegate-is-TerminalViewState surface lookup
- wired as a fallback in the existing cmd-click monitor; the hovered-URL
  path (OSC 8 / detected URLs) is unchanged
A single base directory is not enough to resolve a clicked bare path. The
OSC 7 working directory is stale whenever the shell doesn't report it — it
still reads `~` after a plain `cd` — so `package.json` misses even though
the file is right there.

Resolve against an ordered list of roots instead, most-specific first: the
live kernel cwd (PROC_PIDVNODEPATHINFO, which tracks `cd` with no shell
cooperation), then the session's worktree and its project root. The
filesystem gate still decides, so extra roots only ever add hits that are
real files.
A path that contains a space (`Application Support/settings.json`,
`my notes.md`) is torn in half by the whitespace split, so rejoin adjacent
tokens into wider candidates — but only spans that cover the clicked column,
so the widening can never wander onto a file the user never pointed at. Also
try the shell-unescaped spelling, since a shell echoes `my\ file.ts` for a
name that has no backslash on disk.

Skip the fallback entirely on an `ssh` session: those paths live on the remote
box, and `src/main.rs` exists on both machines, so resolving against the local
filesystem would quietly open the wrong file.

Drop the bring-up NSLog trace.
…t ssh

The fallback skipped `ssh` sessions but not termiod ones, and a termiod
session runs the same `.inMemory` backend as a local shell — so a path
printed by a shell on a VPS was resolved against this Mac's filesystem, and
`src/main.rs` exists on both. Ask the device instead: `isOnThisMac`, over
the `termiodRemoteHost ?? sshHost` rule that `DeviceContext` owns.

That rule was written out by hand in three places, which is how one copy
came to know about only one of the two ways of being elsewhere. Give it a
single home (`deviceAlias(of:)`) and route the others through it.

An unidentifiable surface is now declined rather than assumed local, and
the selected session is no longer a resolution root — on a multi-device
window it can belong to another machine entirely.
`openTerminalLink` resolves a scheme-less link against local disk after only
a `fileExists` check. Both link detectors end there, and the device gate sat
on one of them, so the bug the fallback just fixed was still live through the
front door: a bare path printed by a session on a VPS opened this Mac's file
of the same name.

It is live, not latent. Our pinned libghostty (1.0.16, ghostty 9d8fbd1) has
three branches in its URL regex, not one — the third matches bare relative
paths like `src/main.rs`, and `MOUSE_OVER_LINK` reports the matched text
verbatim. An OSC 8 target needs no detection at all: ghostty hands back the
URI a program wrote, whatever it says.

So gate where the resolution happens rather than where a path is spotted.
`openTerminalLink` now takes the session the link was clicked in and asks
`isOnThisMac` before it touches the filesystem; a real scheme is untouched,
being a handler's business and not this Mac's disk. The hovered-link path
finds its surface by geometry, the same way the bare-path fallback does, and
a surface that can't be named is declined rather than assumed local.

Relative links also resolve against the clicked session's own workspace now.
The selected session stood in before, and on a multi-device window that is
another machine's tree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant