Ids, titles, and URLs printed by the read-only commands are all things a reader wants to open, and right now opening one means selecting the URL and pasting it — or, in the columns that print only an id, constructing the URL by hand. Terminals have supported clickable text for years via OSC 8 hyperlinks, and markfluence already has the data to emit them: every command below already resolves a page URL for its --json output and then throws it away on the human path.
Mechanism
OSC 8 wraps visible text in an escape-delimited URL (ESC]8;;URL ESC\ text ESC]8;;ESC\). termenv.Hyperlink(url, text) emits exactly that, and github.com/muesli/termenv is already in go.mod as an indirect dependency via lipgloss — this promotes it to a direct one and adds no new module.
Support is good but not universal: iTerm2, WezTerm, kitty, Ghostty, VTE (GNOME Terminal), Konsole, foot, Windows Terminal, Alacritty, and tmux 3.4+ all handle it. macOS Terminal.app does not — it ignores the unknown OSC sequence and shows the plain text, which is the benign failure mode. There is no reliable capability query, so the plan is to emit unconditionally on a terminal and accept that an unsupported one degrades to exactly today's output.
What gets linked
| command |
linked |
destination |
source of the URL |
children |
ID column |
the page/folder |
pagetree.Node.URL (already populated) |
info |
id and url rows |
the page |
report.url (already resolved) |
find |
ID and URL columns |
the page/folder |
client.TitleMatch.URL |
search |
title line and URL line |
the hit |
client.SearchMatch.URL |
create |
the id and URL in Created page <id>: <url> |
the new page |
createResult.url (already resolved via client.PageURL) |
update |
the URL in Published v<n>: <url> |
the updated page |
updateResult.url |
attachment-list |
see below — needs a decision first |
|
|
attachment-upload |
see below — nothing to link today |
|
|
info's url row is the case where this pays off most, because its fallback form is the unreadable …/wiki/pages/viewpage.action?pageId=123, and the search title line is the case where the link text is most natural — a title reads like a link where a bare id does not.
Three things that need care
1. Column padding must be measured on the bare text. children.tree, find.table, and attachmentlist.table all pad with widths[j] - len([]rune(cell)). If the escape sequence is in the cell when that runs, every row's alignment breaks by ~20 invisible characters. The wrap has to be the last operation on a cell, after its pad is computed from the unwrapped value.
2. It needs a gate that termenv does not provide. Unlike ui.Match — which is safe to call anywhere because lipgloss renders it unstyled when color is off — termenv.Hyperlink is a plain sprintf and will happily write escapes into a pipe. So this wants a ui.Link(url, text string) string alongside Match, returning text unchanged when jsonMode is set, when IsPiped(), or when the lipgloss profile is Ascii (which is how --no-color reaches it, since root.go implements that flag by setting NO_COLOR). Following search.renderSpans's precedent, the renderers should take the linker as a parameter rather than calling ui.Link directly: tests run with stdout not a terminal, where the real helper returns plain text and a test wired to it would pass against unlinked output.
3. A cell that has no URL must not become a link. find prints - via orDash for a row whose space or URL could not be derived, attachment-list prints - for an unrecorded source, and search omits the URL line entirely when it is empty. A linked - pointing at the site root is worse than a plain one.
The write commands
create and update need nothing new: both already resolve the page URL through client.PageURL and interpolate it into a single success line ([file] Created page 123: https://…, [file] Published v4: https://…). The link wraps the id/URL substring inside the existing fmt.Sprintf, before it reaches ui.Success — so ui.Success and the rest of the ui printers are untouched, and the [file] prefix, warnings, and attachment lines all stay exactly as they are. A create --dry-run has no id or URL by design and takes its own branch already, so there is nothing there to link and nothing to guard.
attachment-upload is the one command in the list with nothing to link at all. It prints fmt.Sprintf("%-8s %s", a.Action, a.Filename), and client.SyncAction carries only Filename and Action — no attachment id, no URL, and no page URL, since the command never prints the page it uploaded to. Making anything there clickable means either plumbing an id/URL through SyncAction (which attachment-list's open question below has to settle first, because it is the same download-URL decision) or a file:// link to the local source file (the same local-link question, and its local path is not in SyncAction either). So it is listed for completeness but should probably ride along with whatever attachment-list decides rather than being designed on its own.
attachment-list is a separate decision
It is the one command in the list with no page-style URL to link, and that is deliberate. From cmd/attachmentlist/json.go:
There is deliberately no download_url: built on the site URL it fails under a scoped token, and built on the request base it would leak the gateway host into reader-facing output. attachment-download is how bytes are fetched.
Two candidates, neither obviously right:
SOURCE → a file:// link to the local source path. A different kind of link from every other one here (local, not Confluence), and it opens the file in whatever the desktop has registered — which for a .png is probably right and for a .md is probably not. The recorded path is relative to the documentation root, so it would need resolving to an absolute path, and the file may not exist on this machine at all: linking a path that 404s locally is worse than printing it plain.
NAME → the attachment's download URL. The --json rationale above does not transfer cleanly, because a clicked link is followed by the browser's own session rather than by the API token — so the scoped-token objection may not apply to a human-facing link the way it applies to a machine-readable field. That is an argument, not a measurement, and the gateway-host objection still stands. Worth verifying against the live instance before committing either way.
Reasonable outcome: do the four page-URL commands, and split attachment-list out to its own issue if the download-link question needs live verification.
Out of scope / unaffected
--json output does not change. Hyperlinks exist only on the human path, so the schema is untouched and no conformance test moves.
- No guarantee changes. This is presentation; R1/R2 are unaffected.
- Exact-output tests stay green for free, since they run with stdout piped and the gate returns plain text there. New coverage goes on
ui.Link itself plus one renderer test per command that passes a fake linker.
Open questions
- Should
--no-color / NO_COLOR suppress hyperlinks? They are not color. Honoring it means one switch for "give me plain output", which seems worth more than the literalism; a separate --no-hyperlinks can wait until someone asks for it.
children and find: link the TITLE column as well as ID? The title is better link text, but children indents titles by depth, so the click target would start at a ragged left edge.
info: the parent row prints a bare id, and both parentType and space are known, so a link is constructible — but the folder URL form should be verified live rather than assumed. Same for linking created/updated authors to their profiles, where creatorID/editorID are already in hand but the profile URL form is unverified.
- Does an archived page's URL (reachable via
find) open usefully, or land on an archive interstitial?
create's two "already exists" failures embed a page URL in the message text (pageIDFailure carries page_id/url for exactly that reason), and those are the lines a reader most wants to click. Linking inside a composed error string is a different shape from linking a column, and pageref.NotFoundMessage-style wording is shared across create/update/fix — worth doing, but probably as a follow-up rather than in the first pass.
- Should the
[file] prefix on create/update output link to the local markdown file? Same local-link question as attachment-list's SOURCE, and it would put a file:// link on every single output line, which is likely more noise than help.
Related: #98 (a friendlier way to browse pages) wants the same thing this does — output you can navigate rather than transcribe.
Ids, titles, and URLs printed by the read-only commands are all things a reader wants to open, and right now opening one means selecting the URL and pasting it — or, in the columns that print only an id, constructing the URL by hand. Terminals have supported clickable text for years via OSC 8 hyperlinks, and markfluence already has the data to emit them: every command below already resolves a page URL for its
--jsonoutput and then throws it away on the human path.Mechanism
OSC 8 wraps visible text in an escape-delimited URL (
ESC]8;;URL ESC\textESC]8;;ESC\).termenv.Hyperlink(url, text)emits exactly that, andgithub.com/muesli/termenvis already ingo.modas an indirect dependency via lipgloss — this promotes it to a direct one and adds no new module.Support is good but not universal: iTerm2, WezTerm, kitty, Ghostty, VTE (GNOME Terminal), Konsole, foot, Windows Terminal, Alacritty, and tmux 3.4+ all handle it. macOS Terminal.app does not — it ignores the unknown OSC sequence and shows the plain text, which is the benign failure mode. There is no reliable capability query, so the plan is to emit unconditionally on a terminal and accept that an unsupported one degrades to exactly today's output.
What gets linked
childrenIDcolumnpagetree.Node.URL(already populated)infoidandurlrowsreport.url(already resolved)findIDandURLcolumnsclient.TitleMatch.URLsearchclient.SearchMatch.URLcreateCreated page <id>: <url>createResult.url(already resolved viaclient.PageURL)updatePublished v<n>: <url>updateResult.urlattachment-listattachment-uploadinfo'surlrow is the case where this pays off most, because its fallback form is the unreadable…/wiki/pages/viewpage.action?pageId=123, and thesearchtitle line is the case where the link text is most natural — a title reads like a link where a bare id does not.Three things that need care
1. Column padding must be measured on the bare text.
children.tree,find.table, andattachmentlist.tableall pad withwidths[j] - len([]rune(cell)). If the escape sequence is in the cell when that runs, every row's alignment breaks by ~20 invisible characters. The wrap has to be the last operation on a cell, after its pad is computed from the unwrapped value.2. It needs a gate that
termenvdoes not provide. Unlikeui.Match— which is safe to call anywhere because lipgloss renders it unstyled when color is off —termenv.Hyperlinkis a plain sprintf and will happily write escapes into a pipe. So this wants aui.Link(url, text string) stringalongsideMatch, returningtextunchanged whenjsonModeis set, whenIsPiped(), or when the lipgloss profile isAscii(which is how--no-colorreaches it, sinceroot.goimplements that flag by settingNO_COLOR). Followingsearch.renderSpans's precedent, the renderers should take the linker as a parameter rather than callingui.Linkdirectly: tests run with stdout not a terminal, where the real helper returns plain text and a test wired to it would pass against unlinked output.3. A cell that has no URL must not become a link.
findprints-viaorDashfor a row whose space or URL could not be derived,attachment-listprints-for an unrecorded source, andsearchomits the URL line entirely when it is empty. A linked-pointing at the site root is worse than a plain one.The write commands
createandupdateneed nothing new: both already resolve the page URL throughclient.PageURLand interpolate it into a single success line ([file] Created page 123: https://…,[file] Published v4: https://…). The link wraps the id/URL substring inside the existingfmt.Sprintf, before it reachesui.Success— soui.Successand the rest of theuiprinters are untouched, and the[file]prefix, warnings, and attachment lines all stay exactly as they are. Acreate --dry-runhas no id or URL by design and takes its own branch already, so there is nothing there to link and nothing to guard.attachment-uploadis the one command in the list with nothing to link at all. It printsfmt.Sprintf("%-8s %s", a.Action, a.Filename), andclient.SyncActioncarries onlyFilenameandAction— no attachment id, no URL, and no page URL, since the command never prints the page it uploaded to. Making anything there clickable means either plumbing an id/URL throughSyncAction(whichattachment-list's open question below has to settle first, because it is the same download-URL decision) or afile://link to the local source file (the same local-link question, and its local path is not inSyncActioneither). So it is listed for completeness but should probably ride along with whateverattachment-listdecides rather than being designed on its own.attachment-listis a separate decisionIt is the one command in the list with no page-style URL to link, and that is deliberate. From
cmd/attachmentlist/json.go:Two candidates, neither obviously right:
SOURCE→ afile://link to the local source path. A different kind of link from every other one here (local, not Confluence), and it opens the file in whatever the desktop has registered — which for a.pngis probably right and for a.mdis probably not. The recorded path is relative to the documentation root, so it would need resolving to an absolute path, and the file may not exist on this machine at all: linking a path that 404s locally is worse than printing it plain.NAME→ the attachment's download URL. The--jsonrationale above does not transfer cleanly, because a clicked link is followed by the browser's own session rather than by the API token — so the scoped-token objection may not apply to a human-facing link the way it applies to a machine-readable field. That is an argument, not a measurement, and the gateway-host objection still stands. Worth verifying against the live instance before committing either way.Reasonable outcome: do the four page-URL commands, and split
attachment-listout to its own issue if the download-link question needs live verification.Out of scope / unaffected
--jsonoutput does not change. Hyperlinks exist only on the human path, so the schema is untouched and no conformance test moves.ui.Linkitself plus one renderer test per command that passes a fake linker.Open questions
--no-color/NO_COLORsuppress hyperlinks? They are not color. Honoring it means one switch for "give me plain output", which seems worth more than the literalism; a separate--no-hyperlinkscan wait until someone asks for it.childrenandfind: link theTITLEcolumn as well asID? The title is better link text, butchildrenindents titles by depth, so the click target would start at a ragged left edge.info: theparentrow prints a bare id, and bothparentTypeandspaceare known, so a link is constructible — but the folder URL form should be verified live rather than assumed. Same for linkingcreated/updatedauthors to their profiles, wherecreatorID/editorIDare already in hand but the profile URL form is unverified.find) open usefully, or land on an archive interstitial?create's two "already exists" failures embed a page URL in the message text (pageIDFailurecarriespage_id/urlfor exactly that reason), and those are the lines a reader most wants to click. Linking inside a composed error string is a different shape from linking a column, andpageref.NotFoundMessage-style wording is shared acrosscreate/update/fix— worth doing, but probably as a follow-up rather than in the first pass.[file]prefix oncreate/updateoutput link to the local markdown file? Same local-link question asattachment-list'sSOURCE, and it would put afile://link on every single output line, which is likely more noise than help.Related: #98 (a friendlier way to browse pages) wants the same thing this does — output you can navigate rather than transcribe.