A file citation in my coding agent looked correct, turned purple on hover, and did absolutely nothing when clicked.
A plain PDF link opened normally. A tool-generated citation such as:
C:/Users/<username>/.wezterm.lua:287-304
did not. The obvious suspects—file association, path separators, the displayed line range—were all plausible and all incomplete. The bug lived in text that was never displayed.
This is a follow-up to Clickable file paths in your terminal: why they work in your AI agent but not your shell. That post explains how OSC 8 hyperlinks differ from WezTerm’s regex-based hyperlink_rules. This one is about the next layer: the visible label can be correct while the invisible OSC 8 target is wrong for the action that consumes it.
What I saw was not what WezTerm opened
OSC 8 hyperlinks contain two independent values:
visible label: C:/Users/<username>/.wezterm.lua:287-304
hidden target: file:///C:/Users/<username>/.wezterm.lua?line=287
The terminal renders the label but passes the target URI to its click handler. Looking only at the screen therefore led me toward stripping :287-304 from the displayed path. That was the wrong evidence layer.
My open-uri handler decoded the target and called the operating system’s default application:
wezterm.on('open-uri', function(_window, _pane, uri)
local path = uri:match('^file:///?(.+)$')
if not path then return end
path = url_decode(path):gsub('/', '\\')
wezterm.open_with(path)
return false
end)
For an ordinary link, this is exactly what I wanted:
file:///C:/Users/<username>/Downloads/guide.pdf
→ C:\Users\<username>\Downloads\guide.pdf
For the agent citation, it produced:
file:///C:/Users/<username>/.wezterm.lua?line=287
→ C:\Users\<username>\.wezterm.lua?line=287
wezterm.open_with correctly handed that string to Windows. Windows correctly failed to find it: ?line=287 is source-location metadata, not part of the physical filename.
Log the event boundary
The useful diagnostic was not another regex edit. It was two temporary log statements at the boundary where WezTerm receives and dispatches the click:
wezterm.on('open-uri', function(_window, _pane, uri)
wezterm.log_info('link-click raw_uri=', uri)
local path = uri:match('^file:///?(.+)$')
if not path then return end
path = menu_text_for_uri(uri)
wezterm.log_info('link-open normalized_path=', path, ' action=open_with')
wezterm.open_with(path)
return false
end)
wezterm.log_info writes through WezTerm’s logging layer. On Windows, the GUI logs were under:
~/.local/share/wezterm/wezterm-gui.exe-log-*.txt
One click settled the question:
link-click raw_uri= file:///C:/Users/<username>/.wezterm.lua?line=287
link-open normalized_path= C:\Users\<username>\.wezterm.lua?line=287 action=open_with
This proved four things at once:
- The colored citation was a real hyperlink, not merely styled text.
- WezTerm’s
open-urievent fired. - The agent encoded line information as a URI query parameter.
- My normalizer passed that metadata into the filesystem path.
After the fix, the same click logged:
link-click raw_uri= file:///C:/Users/<username>/.wezterm.lua?line=23
link-open normalized_path= C:\Users\<username>\.wezterm.lua action=open_with
The file opened. The temporary logging then came out; diagnostic instrumentation should not become permanent configuration by accident.
The fix
Normalize file-link metadata before converting separators or calling the default opener:
local function menu_text_for_uri(uri)
local path = uri:match('^file:///?(.+)$')
if not path then return uri end
path = url_decode(path)
-- Agent citations append ?line=N or :selector;
-- Windows needs only the physical path.
path = path:gsub('[?#].*$', ''):gsub(':[^/\\]+$', '')
if path:match('^~') then
path = wezterm.home_dir .. path:sub(2)
end
return path:gsub('/', '\\')
end
Then keep the click handler boring:
wezterm.on('open-uri', function(_window, _pane, uri)
local path = uri:match('^file:///?(.+)$')
if not path then return end
wezterm.open_with(menu_text_for_uri(uri))
return false
end)
The order matters:
| Step | Why |
|---|---|
Confirm file:// |
Do not rewrite web URLs or custom schemes |
| Percent-decode | Turn %20 and other encoded bytes into the local path |
| Remove query/fragment | Drop URI metadata such as ?line=287 or #L287 |
| Remove agent selector | Handle textual selectors such as :287-304 or :raw |
Expand ~ |
Resolve the home-relative path locally |
| Convert separators | Produce the native Windows path |
Call open_with |
Let the OS choose PDF viewer, browser, Office, editor, and so on |
The broad :[^/\\]+$ rule is safe in this handler because it runs only after the string has been recognized as a local file:// target. It deliberately treats any final colon suffix as agent metadata. If you use NTFS alternate data streams (file.txt:stream), narrow the selector pattern instead.
Preserve the line number when the editor is the destination
Stripping ?line=287 is correct when the goal is open this file with its registered application. It intentionally discards navigation metadata.
If every source file should open in an editor at the cited line, parse the query instead and call the editor’s location-aware CLI. For VS Code:
local path, line = decoded:match('^(.-)%?line=(%d+)$')
if path and line then
wezterm.background_child_process({ 'code', '--goto', path .. ':' .. line })
end
These are different product choices:
| Desired behavior | Correct action |
|---|---|
| Respect Windows file associations | Strip source metadata, then wezterm.open_with(path) |
| Always jump to source location in VS Code | Parse metadata, then code --goto path:line |
| Offer both | Default-open on left click; put editor-specific actions in a context menu |
I chose the third model: left click means the system default; the context menu retains explicit editor actions.
The debugging lesson
A hyperlink has at least three relevant representations:
what the user sees
↓
what OSC 8 declares as the target URI
↓
what the click handler passes to the operating system
A bug can exist between any two layers. Screenshot inspection covers only the first. Reading configuration covers intended behavior. Logging the event boundary exposes actual behavior.
The shortest reliable debugging loop was:
- Add one log at event ingress.
- Add one log immediately before dispatch.
- Click once.
- Compare the two strings.
- Fix the transformation.
- Click once to verify.
- Remove the logs.
That is less work than repeatedly editing regexes against the text visible on screen—and it produces evidence instead of a plausible story.