Skip to content

fix(tui): show where a Markdown link goes, and link local files with their URL - #318

Merged
Kushalkhemka merged 1 commit into
pi-boltfrom
fix/markdown-file-links
Nov 10, 2026
Merged

Kushalkhemka merged 1 commit into
pi-boltfrom
fix/markdown-file-links

Conversation

@Kushalkhemka

Copy link
Copy Markdown
Member

Problem

With hyperlinks on (terminal.hyperlinks, or a terminal Pi detects as supporting OSC 8), a Markdown link that is not a web or mail link is drawn as its text only: no hyperlink, and no URL.

[Open image](file:///C:/Temp/example.png)

1.2.0 draws Open image. Ctrl+click does nothing, and where the link goes is not shown anywhere. The same happens to a relative path ([main](src/main.ts)) or an app scheme. With hyperlinks off, the URL was already printed after the text.

Change

A link's URL is never dropped. With hyperlinks on:

Markdown Drawn as Hyperlink (OSC 8)
[Open image](file:///C:/Temp/example.png) Open image (file:///C:/Temp/example.png) text and URL, to file:///C:/Temp/example.png
[Open local](file://localhost/C:/Temp/example.png) Open local (file://localhost/C:/Temp/example.png) text and URL, to file:///C:/Temp/example.png
[x.png](file:///tmp/x.png) x.png (file:///tmp/x.png) text and URL, to file:///tmp/x.png
[GitHub](https://github.com) GitHub text, to https://github.com (as before)
[Settings](x-apple.systempreferences:...) Settings (x-apple.systempreferences:...) none
[share](file://server/share/x.png) share (file://server/share/x.png) none
[main](src/main.ts) main (src/main.ts) none

The URL is printed after the text in the link URL colour, as it already is with hyperlinks off (unchanged). A link whose text is its URL (<file:///tmp/x.png>) is drawn once. The same lines are drawn in the regular and the fullscreen screen; in fullscreen a click on a file link goes to openBrowser, which shows the file in Finder or the file manager (on Windows it opens images and text files and selects anything else in Explorer), and opens nothing on another host.

Schemes

#167 draws only web and mail links as hyperlinks because a click on anything else can run a program (open on an .app or .command, ShellExecute on a .bat), and because a link's text can name something other than its target. That stays as it is for relative paths, app schemes and files on other hosts: no hyperlink. Their URL is now printed, so the user sees it; a terminal may detect a printed URL and make it clickable itself, as it does for any URL in the output, but Pi does not.

An absolute local file: URL is a hyperlink, text and printed URL together:

  • The target is printed next to the text, so the text cannot hide where it goes, and the hyperlink opens what a click on the printed URL opens. Terminals, Windows Terminal among them, already make a printed file:/// URL clickable, so this adds no target that was not one click away.
  • Pi already draws local files as file:// hyperlinks with their path printed: images it cannot show (imageFallback in terminal-image.ts) and the paths in tool calls (linkPath), which the model writes too.
  • A long URL wraps across lines. Terminal URL detection stops at Pi's line breaks; the hyperlink is opened again on each line, so the whole URL stays clickable.

What counts as local (localFileLinkTarget() in packages/tui/src/utils.ts): file:///... or file://localhost/... (any case), parsed with URL, which also percent-encodes spaces and other characters for the hyperlink. Not a hyperlink: any other host (file://server/share, file://127.0.0.1/), a share written as a path (file:////server/share, file:///\\server\share, file://localhost//server), file: without // (file:C:/x, file:/tmp/x), encoded separators (%2f, %5c), percent-encoded control characters (%00-%1f, %7f, %c2%80-%c2%9f), raw control or format characters (such as a right-to-left override, which can make the printed URL read as another), and URLs longer than 2048 characters (VTE ignores ones over 2083 bytes). Those are drawn with their URL and no hyperlink.

HTML export

The export already links only http, https, mailto, tel and ftp URLs and showed the text alone for anything else. It now shows the URL after the text, escaped, as text (markdown-link-url, in the theme's link URL colour), and still links nothing else.

Streaming

A link renders from its token and the render settings (which include the capabilities), so a block reused while a reply streams in has the lines a fresh render gives. markdown-streaming-blocks.test.ts streams a document of file, web and other links in paragraphs, a list and a table, one character at a time at three widths, with hyperlinks on and off, and compares every render with a fresh one.

Tests

  • packages/tui/test/markdown-link-destinations.test.ts (new): the reporter's cases (labelled file:///C:/..., file://localhost/C:/..., POSIX file:///tmp/x.png, https:, x-apple...:, javascript:, file://server/...) with hyperlinks on and off: the text drawn and the hyperlink under the text and under the URL; the same in TuiMainScreen (what is on the screen and the hyperlinks written) and in TuiAltScreen (a click on the text and on the URL opens the file or web link, and nothing for the others); a long file URL wrapped over several lines; a URL too long to link; localFileLinkTarget accepted and rejected cases.
  • packages/tui/test/terminal-text-sanitize.test.ts: other links have no hyperlink and show their URL.
  • packages/tui/test/markdown-streaming-blocks.test.ts: the streaming case above.
  • packages/coding-agent/test/export-html-session-fields.test.ts: the export shows the URL of a link it does not link to, escaped.

The hyperlinks-on cases, the export case and the changed sanitize test fail on pi-bolt before this change; the hyperlinks-off cases pass on both (unchanged).

Checks (darwin-arm64)

  • npm run check is clean.
  • tui: markdown-link-destinations, terminal-text-sanitize, markdown, markdown-streaming-blocks, tui-alt-screen, tui-render, terminal-image pass. coding-agent: terminal-escape-display, open-browser, export-html-*, tool-execution-component pass.
  • ./test.sh: tui, ai, agent and the other packages pass. Failures elsewhere, none in code this touches: packages/env ("Build the daemon first"), packages/chord (4 timeouts, and 3 tests in upgrades, plugins/build and workers that also fail on their own; chord does not import pi-tui or the coding agent), and coding-agent/test/theme-picker.test.ts (a file-watch wait that passes when run again).
  • bench/e2e_screen.py, this build against the 1.2.0 release build: the same screen at 120 and 80 columns (985 and 1,121 lines); its document has web links only.
  • The same script with a document of the cases above: identical with tmux's default (hyperlinks off). With terminal.hyperlinks: true, only the link lines differ, for example at 120 columns:
1.2.0: Open image
this:  Open image (file:///C:/Temp/example.png)          [OSC 8 to file:///C:/Temp/example.png around text and URL]
1.2.0: Settings and run and share.
this:  Settings (x-apple.systempreferences:com.apple.preference.security) and run (javascript:alert(1)) and share
        (file://server/share/x.png).                     [no OSC 8]
1.2.0: - relative
this:  - relative (src/main.ts)                          [no OSC 8]

The lines after them move down by the lines the URLs add; nothing else differs.

Not tested: Windows Terminal itself (only its handling of an OSC 8 file:/// link as described in the issue), nor how terminals treat file://localhost (the hyperlink uses file:///).

closes #297

…their URL

With hyperlinks on, a Markdown link that is not a web or mail link was
drawn as its text only: [Open image](file:///C:/Temp/example.png) showed
"Open image", with no hyperlink and no URL. Such a link now shows its
URL after its text, as it already did with hyperlinks off. A link to an
absolute file: URL on this computer (file:///... or
file://localhost/...) is a hyperlink, text and printed URL together, as
Pi already links the paths it prints; other hosts, shares, file: without
//, encoded separators or control characters, format characters and
URLs over 2048 characters are not. Other schemes and relative paths
stay without a hyperlink.

The HTML export shows the URL of a link it does not link to after its
text.

closes #297
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T17:31:51.834303Z ea4d3fe PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@opensec-code-review opensec-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenSec security review

✅ No security findings in this change.

The change makes the TUI and the HTML export show a Markdown link's destination in parentheses when the page/TUI does not link to it (fixing #297), and additionally turns file: URLs of local absolute paths into OSC 8 terminal hyperlinks via the new localFileLinkTarget() gate. Reviewed the new escaping in the export viewer, the destination gate for traversal/host/encoded-control bypasses, OSC 8 sequence-injection safety, the click path through TuiAltScreen and openBrowser, and the new tests; the only candidate (widened file: click surface) did not survive verification as a regression introduced by this change.

Notes (uncertain or pre-existing, not counted)
  • packages/tui/src/components/markdown.ts:2306-2311 widens the click surface that #167 removed (docs/releases/1.0.4.md:53-54, open-browser.ts:49-54): in the non-default tuiMode "regular" (TuiMainScreen, no mouse reporting, no click handler) a plain click on a file:///…/x.command or .app link is resolved by the terminal with the OS handler, bypassing openBrowser's reveal-only policy; needs an existing executable at a known absolute path and a user click, and the same capability pre-exists for tool-path links, so it is a note rather than a finding.
  • Hyperlinks-on mode now prints the raw href of non-web links (markdown.ts:2305) where it previously printed nothing; token.href may still contain Cf characters (e.g. U+202E) that markdown's sanitizeTerminalText leaves in place, allowing visual reversal of the displayed destination. Pre-existing for hyperlinks-off and moot given the model can write arbitrary text, so noted only.
  • The export viewer's new text path uses the unsanitized token.href rather than sanitizeMarkdownUrl()'s control-stripped value (template.js:1759-1760); HTML-escaped, so harmless, but control characters can land in the page text.
  • No dependency, manifest, Dockerfile or CI changes in this diff.
1 candidate rejected by verification
  • Markdown links to local file: paths become clickable OSC 8 hyperlinks, re-enabling one-click open of a local file with the OS handler: The click-to-open primitive already ships at head for model-chosen absolute paths via linkPath (packages/coding-agent/src/core/tools/render-utils.ts:23-28), and the change prints the destination next to the label, so a click is a deliberate activation of a shown target (user-decision/social-engineering class excluded by SECURITY.md); default tuiMode fullscreen still routes plain clicks through the hardened openBrowser reveal-instead-of-open path.

Range 55d0c5c129fb..ea4d3fe1e9f7, 387 changed lines. Review by OpenSec Code Review.

@Kushalkhemka
Kushalkhemka merged commit ff20ad3 into pi-bolt Nov 10, 2026
20 checks passed
@Kushalkhemka
Kushalkhemka deleted the fix/markdown-file-links branch November 10, 2026 18:29
opensec-intelligence added a commit that referenced this pull request Nov 10, 2026
Brings #318 (Markdown links show where they go; local file links are
hyperlinks) and #317 (release downloads retry, the Windows update goes past
locked leftovers, npm installs check the registry, `compile --off` keeps its
record on Ctrl-C, updating no longer compiles extensions). No conflicts:
#318's link rendering is beside the HTML comment filter in markdown.ts, and
the `pi-bolt auth login` hint in package-manager-cli.ts keeps COMMAND_NAME.
opensec-intelligence added a commit that referenced this pull request Nov 10, 2026
…nges

Changed: Markdown links show where they go and local file links open (#297,
#318); updating no longer compiles extensions (#317); npm installs check the
npm registry for updates (#303, #317). Fixed: downloads try again after a
dropped connection; a Windows update no longer stops at a locked leftover
file (#316); `pi-bolt compile --off` keeps its record when stopped (#317).
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.

Local-file Markdown links lose their destination after scheme hardening: provide a safe fallback/action

2 participants