From 8215764448a485a5a2e0f871e8c32959455cedd1 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Fri, 24 Jul 2026 12:11:56 +0100 Subject: [PATCH 1/3] feat(terminal): cmd-click bare file paths to open them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../termio/Terminal/TerminalPathClick.swift | 217 ++++++++++++++++++ Sources/termio/TermioStore/TermioStore.swift | 18 +- .../TerminalPathScannerTests.swift | 114 +++++++++ 3 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 Sources/termio/Terminal/TerminalPathClick.swift create mode 100644 Tests/termioTests/TerminalPathScannerTests.swift diff --git a/Sources/termio/Terminal/TerminalPathClick.swift b/Sources/termio/Terminal/TerminalPathClick.swift new file mode 100644 index 00000000..de43a2eb --- /dev/null +++ b/Sources/termio/Terminal/TerminalPathClick.swift @@ -0,0 +1,217 @@ +import AppKit +import Foundation +import GhosttyTerminal + +// Bare file paths that agent CLIs print — `BranchModel.swift`, `src/foo.ts:42`, +// `Sources/App/App.swift:88:15` — 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 the maintainers have declined to add row/column +// parsing — see the design notes on this feature). VS Code makes the same paths +// clickable with its own `terminalLinkParsing` layer, and the reason that layer is +// *stable* rather than a false-positive machine is not its regex: it is that every +// candidate is validated against the filesystem before it becomes a link. termio +// mirrors that shape here — reconstruct the token under a cmd-click from the grid +// text, peel a trailing `:line[:col]`, resolve it against the surface's working +// directory, and open it **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. +enum TerminalPathScanner { + /// A file path successfully reconstructed from a grid row and confirmed to exist. + struct Match: Equatable { + let url: URL + /// 1-based line to jump to, from a `:line` / `:line:col` suffix, else `nil`. + let line: Int? + } + + /// Finds the file path a user cmd-clicked in `row`. Every whitespace token is a + /// candidate; they are tried nearest-to-the-click first and the first one that + /// resolves to an existing regular file wins. Returns `nil` when nothing on the + /// row resolves — the caller then lets the click fall through to the terminal. + /// + /// `nearColumn` is the clicked cell column; it only orders candidates, so a + /// column that is off by a cell (grid padding, a wide glyph) still resolves the + /// right path as long as the row holds a single real file — the common case. + static func resolve(in row: String, nearColumn: Int, workingDirectory: String?) -> Match? { + let ordered = tokens(in: row).sorted { + $0.distance(to: nearColumn) < $1.distance(to: nearColumn) + } + for token in ordered { + if let match = validate(token, workingDirectory: workingDirectory) { + return match + } + } + return nil + } + + // MARK: - Tokenising + + /// A whitespace-delimited run of the row, with the character range it occupies so + /// candidates can be ranked by how close they are to the click column. + private struct Token { + let text: String + let start: Int + let end: Int + + func distance(to column: Int) -> Int { + if column >= start, column < end { return 0 } + return min(abs(column - start), abs(column - end)) + } + } + + private static func tokens(in row: String) -> [Token] { + var result: [Token] = [] + var current = "" + var startIndex = 0 + for (index, character) in row.enumerated() { + if character == " " || character == "\t" { + if !current.isEmpty { + result.append(Token(text: current, start: startIndex, end: index)) + current = "" + } + } else { + if current.isEmpty { startIndex = index } + current.append(character) + } + } + if !current.isEmpty { + result.append(Token(text: current, start: startIndex, end: row.count)) + } + return result + } + + // MARK: - Path + line/column parsing + + /// Wrapping punctuation an agent or shell tends to put around a path — matched + /// brackets/quotes and trailing sentence punctuation — stripped before resolving. + private static let leadingTrim = CharacterSet(charactersIn: "([{<'\"`") + private static let trailingTrim = CharacterSet(charactersIn: ")]}>'\"`,;.") + + /// Git-diff path prefixes (`a/foo`, `b/foo`, …). If the raw token misses on disk + /// but the de-prefixed form hits, that's the file the tool meant. + private static let diffPrefixes = ["a/", "b/", "c/", "i/", "o/", "w/"] + + private static func validate(_ token: Token, workingDirectory: String?) -> Match? { + // Strip wrapping punctuation first — a closing `).` sits *after* the `:line` + // suffix (`(file.swift:10).`), so peeling the line has to see a clean tail. + let (rawPath, line) = peelLineColumn(strip(token.text)) + let stripped = strip(rawPath) + guard !stripped.isEmpty else { return nil } + + for candidate in pathVariants(stripped) { + for url in urls(for: candidate, workingDirectory: workingDirectory) { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue { + return Match(url: url, line: line) + } + } + } + return nil + } + + /// Peels a trailing `:line` or `:line:col` off a token, returning the bare path + /// and the 1-based line (the column is parsed to keep it off the path but isn't + /// used yet — the preview jumps by line). Only trailing all-digit segments are + /// taken, so a path that genuinely contains a colon is left intact. + private static func peelLineColumn(_ token: String) -> (path: String, line: Int?) { + let parts = token.components(separatedBy: ":") + guard parts.count >= 2 else { return (token, nil) } + + var trailingNumbers: [Int] = [] + var lastPathIndex = parts.count - 1 + while lastPathIndex >= 1, trailingNumbers.count < 2, let value = Int(parts[lastPathIndex]) { + trailingNumbers.append(value) + lastPathIndex -= 1 + } + guard !trailingNumbers.isEmpty else { return (token, nil) } + + let path = parts[0...lastPathIndex].joined(separator: ":") + // Reversed: for `file:line:col` we collected [col, line]; for `file:line`, [line]. + let line = trailingNumbers.last + return (path, line) + } + + private static func strip(_ path: String) -> String { + var result = Substring(path) + while let first = result.unicodeScalars.first, leadingTrim.contains(first) { + result = result.dropFirst() + } + while let last = result.unicodeScalars.last, trailingTrim.contains(last) { + result = result.dropLast() + } + return String(result) + } + + private static func pathVariants(_ path: String) -> [String] { + var variants = [path] + for prefix in diffPrefixes where path.hasPrefix(prefix) { + variants.append(String(path.dropFirst(prefix.count))) + } + return variants + } + + private static func urls(for path: String, workingDirectory: String?) -> [URL] { + let expanded = (path as NSString).expandingTildeInPath + if (expanded as NSString).isAbsolutePath { + return [URL(fileURLWithPath: expanded).standardizedFileURL] + } + guard let workingDirectory else { return [] } + let base = URL(fileURLWithPath: workingDirectory, isDirectory: true) + return [URL(fileURLWithPath: expanded, relativeTo: base).standardizedFileURL] + } +} + +extension TermioStore { + /// Cmd-click fallback for a bare file path that libghostty didn't detect as a link. + /// Maps the click to the grid cell under it (reusing the wrapper's own point→cell + /// convention), reads the row's text, and — if a token on that row resolves to a + /// real file — opens it in the read-only preview at the referenced line. Returns + /// `true` when it opened something, so the caller can consume the click; `false` + /// lets the click fall through to the terminal untouched. + @MainActor + func openBarePathUnderCommandClick(_ event: NSEvent) -> Bool { + guard let window = event.window, + let hit = window.contentView?.hitTest(event.locationInWindow), + let terminal = terminalSurfaceView(from: hit), + let state = terminal.delegate as? TerminalViewState, + let metrics = state.surfaceSize, + metrics.cellWidthPixels > 0, metrics.cellHeightPixels > 0, + case .inMemory(let session) = state.configuration.backend, + let viewport = session.readViewportText() else { return false } + + // Same mapping the surface view uses internally: view-local points, y flipped + // to a top-left origin. Cell size is the surface's cell metric (backing + // pixels) brought back to points by the window's scale factor. + let local = terminal.convert(event.locationInWindow, from: nil) + let scale = terminal.window?.backingScaleFactor ?? 2 + let cellWidth = CGFloat(metrics.cellWidthPixels) / scale + let cellHeight = CGFloat(metrics.cellHeightPixels) / scale + guard cellWidth > 0, cellHeight > 0 else { return false } + + let column = Int(local.x / cellWidth) + let rowIndex = Int((terminal.bounds.height - local.y) / cellHeight) + let rows = viewport.components(separatedBy: "\n") + guard rowIndex >= 0, rowIndex < rows.count else { return false } + + let workingDirectory = state.workingDirectory ?? selectedSessionWorkspace + guard let match = TerminalPathScanner.resolve( + in: rows[rowIndex], nearColumn: column, workingDirectory: workingDirectory + ) else { return false } + + openFileReadOnly = true + openFileLine = match.line + openFileURL = match.url + return true + } + + /// Walks up from a hit-tested view to the enclosing terminal surface NSView, whose + /// `delegate` is the `TerminalViewState` (see `TerminalPane.terminalView(matching:)`). + private func terminalSurfaceView(from view: NSView) -> TerminalView? { + var node: NSView? = view + while let current = node { + if let terminal = current as? TerminalView { return terminal } + node = current.superview + } + return nil + } +} diff --git a/Sources/termio/TermioStore/TermioStore.swift b/Sources/termio/TermioStore/TermioStore.swift index 8ed11b6b..2766584a 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -467,11 +467,17 @@ final class TermioStore: ObservableObject { // that link in *both* shells and agent TUIs. Returning nil consumes the event so the click // isn't also delivered to the terminal/app underneath. linkClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in - guard let self, - event.modifierFlags.contains(.command), - let url = TerminalLinkState.hoveredURL else { return event } - self.openTerminalLink(url, surfaceWorkingDirectory: nil) - return nil + guard let self, event.modifierFlags.contains(.command) else { return event } + if let url = TerminalLinkState.hoveredURL { + self.openTerminalLink(url, surfaceWorkingDirectory: nil) + return nil + } + // Nothing libghostty detected under the mouse — fall back to reconstructing + // a bare file path from the grid text at the click (see `TerminalPathScanner`). + // Consumed only when it actually opens a file; otherwise the click passes + // through to the terminal as before. + if self.openBarePathUnderCommandClick(event) { return nil } + return event } syncWatchedFolders() @@ -834,7 +840,7 @@ final class TermioStore: ObservableObject { /// The working directory of the selected session (its worktree, else the project root), used as /// the fall-back base for resolving a relative path when the surface hasn't reported an OSC 7 cwd. - private var selectedSessionWorkspace: String? { + var selectedSessionWorkspace: String? { guard let id = selectedSessionID, let session = session(id), let project = project(for: id) else { return nil } return session.worktreePath ?? project.path diff --git a/Tests/termioTests/TerminalPathScannerTests.swift b/Tests/termioTests/TerminalPathScannerTests.swift new file mode 100644 index 00000000..193e58b7 --- /dev/null +++ b/Tests/termioTests/TerminalPathScannerTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import termio + +/// `TerminalPathScanner` is the stability-critical half of cmd-click-to-open: it decides +/// which bare token on a terminal row is a real file. The guard against false positives is +/// filesystem validation, so these tests run against real files in a temp directory — the +/// same thing the scanner checks at runtime. +final class TerminalPathScannerTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("path-scanner-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + @discardableResult + private func touch(_ relativePath: String) throws -> URL { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try Data().write(to: url) + return url + } + + private func resolve(_ row: String, near column: Int = 0) -> TerminalPathScanner.Match? { + TerminalPathScanner.resolve(in: row, nearColumn: column, workingDirectory: root.path) + } + + // MARK: - The happy paths agents actually print + + func testBareRelativePathResolvesAgainstWorkingDirectory() throws { + let file = try touch("BranchModel.swift") + let match = resolve("edited BranchModel.swift ok", near: 7) + XCTAssertEqual(match?.url, file.standardizedFileURL) + XCTAssertNil(match?.line) + } + + func testNestedRelativePath() throws { + let file = try touch("Sources/App/App.swift") + XCTAssertEqual(resolve("Sources/App/App.swift")?.url, file.standardizedFileURL) + } + + func testLineSuffixIsPeeledAndReported() throws { + try touch("src/module.ts") + let match = resolve("src/module.ts:42") + XCTAssertEqual(match?.url.lastPathComponent, "module.ts") + XCTAssertEqual(match?.line, 42) + } + + func testLineAndColumnSuffixKeepsLineOnly() throws { + try touch("src/module.ts") + let match = resolve("src/module.ts:42:15") + XCTAssertEqual(match?.line, 42) + } + + func testAbsolutePath() throws { + let file = try touch("abs.txt") + let match = TerminalPathScanner.resolve( + in: "see \(file.path):3", nearColumn: 4, workingDirectory: nil + ) + XCTAssertEqual(match?.url, file.standardizedFileURL) + XCTAssertEqual(match?.line, 3) + } + + // MARK: - Real-world noise around the token + + func testSurroundingPunctuationIsStripped() throws { + try touch("file.swift") + XCTAssertNotNil(resolve("here (file.swift:10).")) + XCTAssertEqual(resolve("here (file.swift:10).")?.line, 10) + try touch("trailing.swift") + XCTAssertNotNil(resolve("open trailing.swift, then")) + } + + func testGitDiffPrefixIsTriedWithoutPrefix() throws { + let file = try touch("lib/core.rs") + // Diff output prints `b/lib/core.rs`; the real file has no `b/`. + XCTAssertEqual(resolve("b/lib/core.rs:8")?.url, file.standardizedFileURL) + } + + // MARK: - The false-positive guard + + func testNonexistentPathReturnsNil() { + XCTAssertNil(resolve("nope/missing.swift:3")) + } + + func testPlainWordsReturnNil() { + XCTAssertNil(resolve("the quick brown fox")) + } + + func testDirectoryIsNotAMatch() throws { + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Sources"), withIntermediateDirectories: true + ) + XCTAssertNil(resolve("Sources")) + } + + // MARK: - Column disambiguation + + func testNearestColumnWinsWhenTwoFilesOnARow() throws { + let left = try touch("left.swift") + let right = try touch("right.swift") + let row = "left.swift and right.swift" + // "right.swift" starts at column 16. + XCTAssertEqual(resolve(row, near: 18)?.url, right.standardizedFileURL) + XCTAssertEqual(resolve(row, near: 2)?.url, left.standardizedFileURL) + } +} From 07d227323a864a10fde28d767d997e4148e24a28 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Fri, 24 Jul 2026 21:03:21 +0100 Subject: [PATCH 2/3] wip(terminal): resolve bare paths against kernel cwd + project roots Resolve a clicked bare path against multiple base dirs (live kernel cwd via PROC_PIDVNODEPATHINFO, session worktree, project root) instead of only the OSC 7 cwd, which is stale when the shell doesn't report it. Carries temporary [PATHCLICK] trace logging (to be removed before merge). --- .../termio/Terminal/TerminalPathClick.swift | 134 ++++++++++++++---- Sources/termio/TermioStore/TermioStore.swift | 1 + .../TerminalPathScannerTests.swift | 16 ++- 3 files changed, 120 insertions(+), 31 deletions(-) diff --git a/Sources/termio/Terminal/TerminalPathClick.swift b/Sources/termio/Terminal/TerminalPathClick.swift index de43a2eb..bcdccf17 100644 --- a/Sources/termio/Terminal/TerminalPathClick.swift +++ b/Sources/termio/Terminal/TerminalPathClick.swift @@ -31,18 +31,30 @@ enum TerminalPathScanner { /// `nearColumn` is the clicked cell column; it only orders candidates, so a /// column that is off by a cell (grid padding, a wide glyph) still resolves the /// right path as long as the row holds a single real file — the common case. - static func resolve(in row: String, nearColumn: Int, workingDirectory: String?) -> Match? { + /// + /// `baseDirectories` are the roots a relative path is tried against, in order — + /// the terminal's own cwd, then the session's worktree / project root. Trusting + /// only the terminal-reported cwd is fragile (a shell that doesn't emit OSC 7, or + /// `ls` run in a subdir leaves the cwd stale); resolving against the project root + /// too is what lets `package.json` open even when the cwd read is `~`. + static func resolve(in row: String, nearColumn: Int, baseDirectories: [String]) -> Match? { + let bases = orderedUnique(baseDirectories) let ordered = tokens(in: row).sorted { $0.distance(to: nearColumn) < $1.distance(to: nearColumn) } for token in ordered { - if let match = validate(token, workingDirectory: workingDirectory) { + if let match = validate(token, baseDirectories: bases) { return match } } return nil } + private static func orderedUnique(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } + // MARK: - Tokenising /// A whitespace-delimited run of the row, with the character range it occupies so @@ -90,7 +102,7 @@ enum TerminalPathScanner { /// but the de-prefixed form hits, that's the file the tool meant. private static let diffPrefixes = ["a/", "b/", "c/", "i/", "o/", "w/"] - private static func validate(_ token: Token, workingDirectory: String?) -> Match? { + private static func validate(_ token: Token, baseDirectories: [String]) -> Match? { // Strip wrapping punctuation first — a closing `).` sits *after* the `:line` // suffix (`(file.swift:10).`), so peeling the line has to see a clean tail. let (rawPath, line) = peelLineColumn(strip(token.text)) @@ -98,7 +110,7 @@ enum TerminalPathScanner { guard !stripped.isEmpty else { return nil } for candidate in pathVariants(stripped) { - for url in urls(for: candidate, workingDirectory: workingDirectory) { + for url in urls(for: candidate, baseDirectories: baseDirectories) { var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue { @@ -150,14 +162,15 @@ enum TerminalPathScanner { return variants } - private static func urls(for path: String, workingDirectory: String?) -> [URL] { + private static func urls(for path: String, baseDirectories: [String]) -> [URL] { let expanded = (path as NSString).expandingTildeInPath if (expanded as NSString).isAbsolutePath { return [URL(fileURLWithPath: expanded).standardizedFileURL] } - guard let workingDirectory else { return [] } - let base = URL(fileURLWithPath: workingDirectory, isDirectory: true) - return [URL(fileURLWithPath: expanded, relativeTo: base).standardizedFileURL] + return baseDirectories.map { base in + URL(fileURLWithPath: expanded, relativeTo: URL(fileURLWithPath: base, isDirectory: true)) + .standardizedFileURL + } } } @@ -170,48 +183,111 @@ extension TermioStore { /// lets the click fall through to the terminal untouched. @MainActor func openBarePathUnderCommandClick(_ event: NSEvent) -> Bool { - guard let window = event.window, - let hit = window.contentView?.hitTest(event.locationInWindow), - let terminal = terminalSurfaceView(from: hit), - let state = terminal.delegate as? TerminalViewState, - let metrics = state.surfaceSize, - metrics.cellWidthPixels > 0, metrics.cellHeightPixels > 0, - case .inMemory(let session) = state.configuration.backend, - let viewport = session.readViewportText() else { return false } + guard let window = event.window else { pathClickLog("no window"); return false } + let windowPoint = event.locationInWindow + + // Find the surface by enumerating terminal views and testing which one's frame + // contains the click — robust to overlays/hosting layers that a plain `hitTest` + // would return instead (ghostty disables link detection under mouse reporting, + // so this fallback must not depend on the same z-order hitTest gives). + guard let (terminal, state) = terminalSurface(at: windowPoint, in: window) else { + pathClickLog("no terminal surface under click") + return false + } + guard let metrics = state.surfaceSize, + metrics.cellWidthPixels > 0, metrics.cellHeightPixels > 0 else { + pathClickLog("no surfaceSize") + return false + } + guard case .inMemory(let session) = state.configuration.backend else { + pathClickLog("backend not inMemory") + return false + } + guard let viewport = session.readViewportText() else { + pathClickLog("nil viewport text") + return false + } // Same mapping the surface view uses internally: view-local points, y flipped // to a top-left origin. Cell size is the surface's cell metric (backing // pixels) brought back to points by the window's scale factor. - let local = terminal.convert(event.locationInWindow, from: nil) + let local = terminal.convert(windowPoint, from: nil) let scale = terminal.window?.backingScaleFactor ?? 2 let cellWidth = CGFloat(metrics.cellWidthPixels) / scale let cellHeight = CGFloat(metrics.cellHeightPixels) / scale - guard cellWidth > 0, cellHeight > 0 else { return false } + guard cellWidth > 0, cellHeight > 0 else { pathClickLog("zero cell size"); return false } let column = Int(local.x / cellWidth) let rowIndex = Int((terminal.bounds.height - local.y) / cellHeight) let rows = viewport.components(separatedBy: "\n") - guard rowIndex >= 0, rowIndex < rows.count else { return false } + let bases = pathBaseDirectories(for: state) + pathClickLog("local=\(local) bounds=\(terminal.bounds.size) cell=\(cellWidth)x\(cellHeight) " + + "col=\(column) row=\(rowIndex) rows=\(rows.count) bases=\(bases)") + guard rowIndex >= 0, rowIndex < rows.count else { pathClickLog("row out of range"); return false } + pathClickLog("rowText=<\(rows[rowIndex])>") - let workingDirectory = state.workingDirectory ?? selectedSessionWorkspace guard let match = TerminalPathScanner.resolve( - in: rows[rowIndex], nearColumn: column, workingDirectory: workingDirectory - ) else { return false } + in: rows[rowIndex], nearColumn: column, baseDirectories: bases + ) else { + pathClickLog("no path resolved on row") + return false + } + pathClickLog("OPEN \(match.url.path) line=\(match.line.map(String.init) ?? "nil")") openFileReadOnly = true openFileLine = match.line openFileURL = match.url return true } - /// Walks up from a hit-tested view to the enclosing terminal surface NSView, whose - /// `delegate` is the `TerminalViewState` (see `TerminalPane.terminalView(matching:)`). - private func terminalSurfaceView(from view: NSView) -> TerminalView? { - var node: NSView? = view - while let current = node { - if let terminal = current as? TerminalView { return terminal } - node = current.superview + /// The terminal surface (and its state) whose frame contains `windowPoint`. Walks + /// the whole view tree and geometry-tests each `TerminalView` rather than trusting + /// `hitTest`, so a transparent overlay above the grid can't hide the surface. + private func terminalSurface( + at windowPoint: CGPoint, in window: NSWindow + ) -> (TerminalView, TerminalViewState)? { + guard let root = window.contentView else { return nil } + var terminals: [TerminalView] = [] + collectTerminalViews(under: root, into: &terminals) + pathClickLog("terminalViews=\(terminals.count)") + for terminal in terminals { + let local = terminal.convert(windowPoint, from: nil) + guard terminal.bounds.contains(local), + let state = terminal.delegate as? TerminalViewState else { continue } + return (terminal, state) } return nil } + + private func collectTerminalViews(under view: NSView, into out: inout [TerminalView]) { + if let terminal = view as? TerminalView { out.append(terminal) } + for subview in view.subviews { collectTerminalViews(under: subview, into: &out) } + } + + /// Roots a relative path from the clicked surface is resolved against, most-specific + /// first: the terminal's own reported cwd, then the owning session's worktree and + /// project root. The project root is the reliable anchor — the terminal cwd can be + /// stale or unreported (no OSC 7), which is why a bare `package.json` must still + /// resolve against the project the surface belongs to. + private func pathBaseDirectories(for state: TerminalViewState) -> [String] { + var bases: [String] = [] + if let id = surfaces.first(where: { $0.value === state })?.key { + // Live cwd straight from the kernel (`PROC_PIDVNODEPATHINFO`), the reliable + // anchor: it tracks a plain `cd` even when the shell never emits OSC 7 — the + // exact case where the OSC 7 `workingDirectory` read is stale (still `~`). + if let liveCwd = ptyProcesses[id]?.currentWorkingDirectory() { bases.append(liveCwd) } + if let worktree = session(id)?.worktreePath { bases.append(worktree) } + if let project = project(for: id) { bases.append(project.path) } + } + if let cwd = state.workingDirectory { bases.append(cwd) } + if let workspace = selectedSessionWorkspace { bases.append(workspace) } + return bases + } +} + +/// Temporary trace for the cmd-click path fallback while it's being brought up. Only +/// fires on a cmd-left-click with no libghostty-detected link, so it is not chatty. +@MainActor +private func pathClickLog(_ message: @autoclosure () -> String) { + NSLog("[PATHCLICK] %@", message()) } diff --git a/Sources/termio/TermioStore/TermioStore.swift b/Sources/termio/TermioStore/TermioStore.swift index 2766584a..e33ddfd3 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -468,6 +468,7 @@ final class TermioStore: ObservableObject { // isn't also delivered to the terminal/app underneath. linkClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in guard let self, event.modifierFlags.contains(.command) else { return event } + NSLog("[PATHCLICK] cmd-leftMouseDown hovered=%@", TerminalLinkState.hoveredURL ?? "nil") if let url = TerminalLinkState.hoveredURL { self.openTerminalLink(url, surfaceWorkingDirectory: nil) return nil diff --git a/Tests/termioTests/TerminalPathScannerTests.swift b/Tests/termioTests/TerminalPathScannerTests.swift index 193e58b7..ef61d925 100644 --- a/Tests/termioTests/TerminalPathScannerTests.swift +++ b/Tests/termioTests/TerminalPathScannerTests.swift @@ -29,7 +29,7 @@ final class TerminalPathScannerTests: XCTestCase { } private func resolve(_ row: String, near column: Int = 0) -> TerminalPathScanner.Match? { - TerminalPathScanner.resolve(in: row, nearColumn: column, workingDirectory: root.path) + TerminalPathScanner.resolve(in: row, nearColumn: column, baseDirectories: [root.path]) } // MARK: - The happy paths agents actually print @@ -62,7 +62,7 @@ final class TerminalPathScannerTests: XCTestCase { func testAbsolutePath() throws { let file = try touch("abs.txt") let match = TerminalPathScanner.resolve( - in: "see \(file.path):3", nearColumn: 4, workingDirectory: nil + in: "see \(file.path):3", nearColumn: 4, baseDirectories: [] ) XCTAssertEqual(match?.url, file.standardizedFileURL) XCTAssertEqual(match?.line, 3) @@ -84,6 +84,18 @@ final class TerminalPathScannerTests: XCTestCase { XCTAssertEqual(resolve("b/lib/core.rs:8")?.url, file.standardizedFileURL) } + func testResolvesAgainstProjectRootWhenCwdIsWrong() throws { + // The bug from the field: terminal reported cwd `~`, but the file lives in the + // project. Passing both bases (stale cwd first, project root second) must still + // open it — mirrors VS Code/Zed validating against the workspace, not just cwd. + let file = try touch("package.json") + let match = TerminalPathScanner.resolve( + in: " package.json", nearColumn: 3, + baseDirectories: ["/Users/nobody", root.path] + ) + XCTAssertEqual(match?.url, file.standardizedFileURL) + } + // MARK: - The false-positive guard func testNonexistentPathReturnsNil() { From 93e735cb635db6a24e397ff86488c75d68d53eb7 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 12 Aug 2026 23:10:34 +0100 Subject: [PATCH 3/3] feat(terminal): resolve bare paths with spaces, skip remote surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../termio/Terminal/TerminalPathClick.swift | 132 ++++++++++++------ Sources/termio/TermioStore/TermioStore.swift | 1 - .../TerminalPathScannerTests.swift | 27 ++++ 3 files changed, 119 insertions(+), 41 deletions(-) diff --git a/Sources/termio/Terminal/TerminalPathClick.swift b/Sources/termio/Terminal/TerminalPathClick.swift index bcdccf17..1be17a98 100644 --- a/Sources/termio/Terminal/TerminalPathClick.swift +++ b/Sources/termio/Terminal/TerminalPathClick.swift @@ -39,11 +39,8 @@ enum TerminalPathScanner { /// too is what lets `package.json` open even when the cwd read is `~`. static func resolve(in row: String, nearColumn: Int, baseDirectories: [String]) -> Match? { let bases = orderedUnique(baseDirectories) - let ordered = tokens(in: row).sorted { - $0.distance(to: nearColumn) < $1.distance(to: nearColumn) - } - for token in ordered { - if let match = validate(token, baseDirectories: bases) { + for candidate in candidates(in: row, nearColumn: nearColumn) { + if let match = validate(candidate, baseDirectories: bases) { return match } } @@ -57,17 +54,54 @@ enum TerminalPathScanner { // MARK: - Tokenising + /// How many adjacent whitespace tokens may be rejoined into one candidate. Four + /// covers the shapes that occur in practice (`Application Support/…`, `My Project + /// Notes.md`) while keeping the widening from wandering across a whole row. + private static let maximumJoinedTokens = 4 + /// A whitespace-delimited run of the row, with the character range it occupies so /// candidates can be ranked by how close they are to the click column. private struct Token { let text: String let start: Int let end: Int + } - func distance(to column: Int) -> Int { - if column >= start, column < end { return 0 } - return min(abs(column - start), abs(column - end)) + /// Every spelling on the row worth checking, precise-first: each whitespace token, + /// plus the runs of adjacent tokens that a path containing a space would have been + /// torn into. Ordered by distance to the click and then by width, so the token the + /// user actually pointed at is checked before any widened span — the filesystem + /// gate then decides, and the widening only ever costs extra misses. + private static func candidates(in row: String, nearColumn column: Int) -> [String] { + let tokens = tokens(in: row) + guard !tokens.isEmpty else { return [] } + let characters = Array(row) + + var ranked: [(text: String, distance: Int, width: Int)] = [] + for start in tokens.indices { + for end in start.. start, !(span.lower <= column && column < span.upper) { continue } + let text = end == start + ? tokens[start].text + : String(characters[span.lower.. Int { + if column >= lower, column < upper { return 0 } + return min(abs(column - lower), abs(column - upper)) } private static func tokens(in row: String) -> [Token] { @@ -102,10 +136,10 @@ enum TerminalPathScanner { /// but the de-prefixed form hits, that's the file the tool meant. private static let diffPrefixes = ["a/", "b/", "c/", "i/", "o/", "w/"] - private static func validate(_ token: Token, baseDirectories: [String]) -> Match? { + private static func validate(_ token: String, baseDirectories: [String]) -> Match? { // Strip wrapping punctuation first — a closing `).` sits *after* the `:line` // suffix (`(file.swift:10).`), so peeling the line has to see a clean tail. - let (rawPath, line) = peelLineColumn(strip(token.text)) + let (rawPath, line) = peelLineColumn(strip(token)) let stripped = strip(rawPath) guard !stripped.isEmpty else { return nil } @@ -155,13 +189,42 @@ enum TerminalPathScanner { } private static func pathVariants(_ path: String) -> [String] { - var variants = [path] - for prefix in diffPrefixes where path.hasPrefix(prefix) { - variants.append(String(path.dropFirst(prefix.count))) + var variants: [String] = [] + func add(_ value: String) { + guard !value.isEmpty, !variants.contains(value) else { return } + variants.append(value) + for prefix in diffPrefixes where value.hasPrefix(prefix) { + let dePrefixed = String(value.dropFirst(prefix.count)) + guard !dePrefixed.isEmpty, !variants.contains(dePrefixed) else { continue } + variants.append(dePrefixed) + } } + add(path) + add(unescapingShellBackslashes(path)) return variants } + /// Folds shell backslash escapes into the character they escape: a shell echoes + /// `src/my\ file.ts`, but the name on disk is `src/my file.ts`. + private static func unescapingShellBackslashes(_ path: String) -> String { + guard path.contains("\\") else { return path } + var result = "" + var escaping = false + for character in path { + if escaping { + result.append(character) + escaping = false + } else if character == "\\" { + escaping = true + } else { + result.append(character) + } + } + // A lone trailing backslash isn't an escape; keep it so the spelling stays honest. + if escaping { result.append("\\") } + return result + } + private static func urls(for path: String, baseDirectories: [String]) -> [URL] { let expanded = (path as NSString).expandingTildeInPath if (expanded as NSString).isAbsolutePath { @@ -183,7 +246,7 @@ extension TermioStore { /// lets the click fall through to the terminal untouched. @MainActor func openBarePathUnderCommandClick(_ event: NSEvent) -> Bool { - guard let window = event.window else { pathClickLog("no window"); return false } + guard let window = event.window else { return false } let windowPoint = event.locationInWindow // Find the surface by enumerating terminal views and testing which one's frame @@ -191,20 +254,21 @@ extension TermioStore { // would return instead (ghostty disables link detection under mouse reporting, // so this fallback must not depend on the same z-order hitTest gives). guard let (terminal, state) = terminalSurface(at: windowPoint, in: window) else { - pathClickLog("no terminal surface under click") return false } + let surfaceID = surfaces.first(where: { $0.value === state })?.key + // The paths an `ssh` session prints live on the remote box. Resolving them + // against the local filesystem is worse than doing nothing: `src/main.rs` + // exists on both machines, so the click would quietly open the wrong file. + if let surfaceID, session(surfaceID)?.sshHost != nil { return false } guard let metrics = state.surfaceSize, metrics.cellWidthPixels > 0, metrics.cellHeightPixels > 0 else { - pathClickLog("no surfaceSize") return false } guard case .inMemory(let session) = state.configuration.backend else { - pathClickLog("backend not inMemory") return false } guard let viewport = session.readViewportText() else { - pathClickLog("nil viewport text") return false } @@ -215,25 +279,21 @@ extension TermioStore { let scale = terminal.window?.backingScaleFactor ?? 2 let cellWidth = CGFloat(metrics.cellWidthPixels) / scale let cellHeight = CGFloat(metrics.cellHeightPixels) / scale - guard cellWidth > 0, cellHeight > 0 else { pathClickLog("zero cell size"); return false } + guard cellWidth > 0, cellHeight > 0 else { return false } let column = Int(local.x / cellWidth) let rowIndex = Int((terminal.bounds.height - local.y) / cellHeight) let rows = viewport.components(separatedBy: "\n") - let bases = pathBaseDirectories(for: state) - pathClickLog("local=\(local) bounds=\(terminal.bounds.size) cell=\(cellWidth)x\(cellHeight) " - + "col=\(column) row=\(rowIndex) rows=\(rows.count) bases=\(bases)") - guard rowIndex >= 0, rowIndex < rows.count else { pathClickLog("row out of range"); return false } - pathClickLog("rowText=<\(rows[rowIndex])>") + guard rowIndex >= 0, rowIndex < rows.count else { return false } guard let match = TerminalPathScanner.resolve( - in: rows[rowIndex], nearColumn: column, baseDirectories: bases + in: rows[rowIndex], + nearColumn: column, + baseDirectories: pathBaseDirectories(surfaceID: surfaceID, state: state) ) else { - pathClickLog("no path resolved on row") return false } - pathClickLog("OPEN \(match.url.path) line=\(match.line.map(String.init) ?? "nil")") openFileReadOnly = true openFileLine = match.line openFileURL = match.url @@ -249,7 +309,6 @@ extension TermioStore { guard let root = window.contentView else { return nil } var terminals: [TerminalView] = [] collectTerminalViews(under: root, into: &terminals) - pathClickLog("terminalViews=\(terminals.count)") for terminal in terminals { let local = terminal.convert(windowPoint, from: nil) guard terminal.bounds.contains(local), @@ -269,25 +328,18 @@ extension TermioStore { /// project root. The project root is the reliable anchor — the terminal cwd can be /// stale or unreported (no OSC 7), which is why a bare `package.json` must still /// resolve against the project the surface belongs to. - private func pathBaseDirectories(for state: TerminalViewState) -> [String] { + private func pathBaseDirectories(surfaceID: UUID?, state: TerminalViewState) -> [String] { var bases: [String] = [] - if let id = surfaces.first(where: { $0.value === state })?.key { + if let surfaceID { // Live cwd straight from the kernel (`PROC_PIDVNODEPATHINFO`), the reliable // anchor: it tracks a plain `cd` even when the shell never emits OSC 7 — the // exact case where the OSC 7 `workingDirectory` read is stale (still `~`). - if let liveCwd = ptyProcesses[id]?.currentWorkingDirectory() { bases.append(liveCwd) } - if let worktree = session(id)?.worktreePath { bases.append(worktree) } - if let project = project(for: id) { bases.append(project.path) } + if let liveCwd = ptyProcesses[surfaceID]?.currentWorkingDirectory() { bases.append(liveCwd) } + if let worktree = session(surfaceID)?.worktreePath { bases.append(worktree) } + if let project = project(for: surfaceID) { bases.append(project.path) } } if let cwd = state.workingDirectory { bases.append(cwd) } if let workspace = selectedSessionWorkspace { bases.append(workspace) } return bases } } - -/// Temporary trace for the cmd-click path fallback while it's being brought up. Only -/// fires on a cmd-left-click with no libghostty-detected link, so it is not chatty. -@MainActor -private func pathClickLog(_ message: @autoclosure () -> String) { - NSLog("[PATHCLICK] %@", message()) -} diff --git a/Sources/termio/TermioStore/TermioStore.swift b/Sources/termio/TermioStore/TermioStore.swift index e33ddfd3..2766584a 100644 --- a/Sources/termio/TermioStore/TermioStore.swift +++ b/Sources/termio/TermioStore/TermioStore.swift @@ -468,7 +468,6 @@ final class TermioStore: ObservableObject { // isn't also delivered to the terminal/app underneath. linkClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in guard let self, event.modifierFlags.contains(.command) else { return event } - NSLog("[PATHCLICK] cmd-leftMouseDown hovered=%@", TerminalLinkState.hoveredURL ?? "nil") if let url = TerminalLinkState.hoveredURL { self.openTerminalLink(url, surfaceWorkingDirectory: nil) return nil diff --git a/Tests/termioTests/TerminalPathScannerTests.swift b/Tests/termioTests/TerminalPathScannerTests.swift index ef61d925..1ba5ece7 100644 --- a/Tests/termioTests/TerminalPathScannerTests.swift +++ b/Tests/termioTests/TerminalPathScannerTests.swift @@ -96,6 +96,33 @@ final class TerminalPathScannerTests: XCTestCase { XCTAssertEqual(match?.url, file.standardizedFileURL) } + // MARK: - Paths that contain a space + + func testPathWithSpaceIsRejoinedFromAdjacentTokens() throws { + let file = try touch("Application Support/settings.json") + // The shell prints the name unquoted, so a whitespace split tears it in half. + let row = "wrote Application Support/settings.json" + XCTAssertEqual(resolve(row, near: 8)?.url, file.standardizedFileURL) + XCTAssertEqual(resolve(row, near: 25)?.url, file.standardizedFileURL) + } + + func testQuotedPathWithSpace() throws { + let file = try touch("my notes.md") + XCTAssertEqual(resolve("cat \"my notes.md\"", near: 6)?.url, file.standardizedFileURL) + } + + func testBackslashEscapedSpace() throws { + let file = try touch("my notes.md") + XCTAssertEqual(resolve("cat my\\ notes.md", near: 5)?.url, file.standardizedFileURL) + } + + func testRejoiningNeverWidensAwayFromTheClick() throws { + try touch("Application Support/settings.json") + // Clicking `wrote` must not drag the span rightwards onto a file the user + // never pointed at — only spans covering the click are considered. + XCTAssertNil(resolve("wrote Application Support/settings.json", near: 2)) + } + // MARK: - The false-positive guard func testNonexistentPathReturnsNil() {