Skip to content

Make the URLs a world prints clickable, wrapped or not - #23

Merged
HarryCordewener merged 2 commits into
mainfrom
feat/auto-linkify-urls
Aug 11, 2026
Merged

Make the URLs a world prints clickable, wrapped or not#23
HarryCordewener merged 2 commits into
mainfrom
feat/auto-linkify-urls

Conversation

@HarryCordewener

@HarryCordewenerHarryCordewener commented Aug 11, 2026

Copy link
Copy Markdown
Member

A game prints https://example.org/very/long/path. The terminal emulator finds it and makes it clickable — across the terminal row. An output pane is narrower than the row, so the URL wraps and the emulator sees https://exa on one row and mple.com/page on the next, with a divider and possibly another pane's output between them. Neither half is a URL, neither is clickable, and nothing says so.

This client marks the span itself, which moves the decision to the layer that knows where the line really ends: MarkupParser splits a [link=…] across every row it wraps onto and MarkupControl hit-tests each row. OSC 8 was not an option — the compositor's cells carry no such attribute.

What is in it

  • UrlDetector (Core). Finds http:// / https:// in a StyledLine and gives those characters a SpanInteraction. The text is untouched; only span boundaries move.
    • Over the whole line, never span by span: a server may change colour mid-URL, and matching per span finds two half-URLs and produces two links to two truncated targets — the same defect as the wrap, one layer down. Same shape as EmojiSubstitutor.ApplyToLine.
    • After the emoji substitution, so a link's target is exactly the text under it. A span whose visible text and destination differ is the shape of a phishing link.
    • Skips any run overlapping an existing interaction — what MXP marked up is MXP's.
    • Two schemes only. No www., no bare host, no mailto:: the output is eventually handed to the desktop, so what this can name is a security property. Trailing punctuation and unbalanced closers go back to the sentence (a wiki URL keeps its parens); a scheme inside a URL does not start a second link; there is a length cap.
  • One shown line, two destinations.ProcessOutputLine computes it once and hands the same line to SpawnLine and Print. A capture used to receive result.Line while the main window received the substituted one, so one line read differently in the two panes it landed in — fixed on the way past.
  • Clicking an http(s) link in a pane opens the desktop's browser (ExternalBrowser). The built-in web view keeps its own anchors and /web <url>: LinkAction.Web is routed by the surface the click came from, not by the payload, or the built-in browser would eject you to Firefox on its first in-page link. The window id is a trusted parameter set where the handler is subscribed, never server text.
  • The scheme gate is at the moment of opening, and it is the security boundary. The detector only ever produces http(s), but this path also carries what a server marked up: file:///…, javascript:, ms-msdt:. Handing those to xdg-open/ShellExecute is letting the world choose which program runs. Launched as ProcessStartInfo.FileName (never a shell string), and what is launched is Uri.AbsoluteUri — what .NET parsed, not a second reading of the same bytes.
  • The launcher is caller-supplied and null by default, the fourth member of the save:/logRoot:/restore: family: a snapshot and a test start no browser, and an app with no opener refuses out loud.
  • F7 ▸ detect links in output, default on, read per line. Ingest-time like strip incoming colour and emoji substitution: unticking it stops the next line rather than rewriting history. Documented as that rather than pretended otherwise — it is not the timestamp gutter's situation, because a pane's history is markup by then.

Verification

dotnet build SharpMUTerm.slnx — 0 warnings, 0 errors. All five suites, run directly: Core 869, Tui 1550, Graphics 83, Scripting 42, Web 37 — 0 failed.

New --view links renders the defect: a split, so the pane is narrower than the terminal. Decoded from the frame, the underline runs to the pane's edge and continues on the next row —

10 | The noticeboard reads: patch notes at
11 | https://aetherfall.example/news/2026/the-long- <- underlined to the edge
12 | winter-patch-notes?from=board#changes <- and continuing here
13 | and the map (see https://aetherfall.example/map) <- underline stops before the ')'

Two existing tests in LinkSchemeSecurityTests changed destination rather than behaviour (a hyperlink now opens the browser; a forged mux:send: payload is now refused by the http(s) gate instead of failing inside the web view), and the three F7 screen tests moved by one row.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN

Summary by CodeRabbit

  • New Features

    • Automatically detects HTTP and HTTPS URLs in terminal output, including wrapped links and links spanning styled text.
    • Added a Text & ANSI setting to enable or disable link detection.
    • Added a links snapshot view for reviewing detected links.
  • Improvements

    • Links opened from non-web panes now launch in the system browser.
    • Web-view links continue navigating internally.
    • Invalid, unsupported, or unavailable links display a notice instead of opening.

The terminal's own URL detection works across the terminal *row*, and an
output pane is narrower than one. A long URL wrapped inside a pane is
`https://exa` on one row and `mple.com/page` on the next, with a divider
and possibly another pane's output in between: neither half is a URL,
neither is clickable, and nothing says so.
`UrlDetector` (Core) marks the span itself, which moves the decision to
the layer that knows where the line really ends — MarkupParser splits a
`[link=…]` across every row it wraps onto and MarkupControl hit-tests
each row. It runs over the whole line rather than span by span, because
a server may change colour mid-URL and matching per span produces two
links to two truncated targets: the same defect one layer down. It runs
after the emoji substitution so a link's target is exactly the text under
it, and skips any run overlapping a span the server already marked up.
`ProcessOutputLine` now computes the shown line once and hands the same
line to `SpawnLine` and to `Print`. A capture used to receive
`result.Line` while the main window received the substituted one, so one
line read differently in the two panes it landed in.
Clicking an http(s) link in a pane opens the desktop's browser;
the built-in web view keeps its own anchors and `/web <url>`. The
destination is decided by the surface the click came from, not by the
payload — otherwise the built-in browser would eject you on its first
in-page link. The scheme is gated at the moment of opening rather than
in the detector, because that path also carries what a *server* marked
up: `file://`, `javascript:` and the schemes a desktop registers to
applications would otherwise let the world choose which program runs.
The launcher is caller-supplied and null by default, like save/logRoot/
restore, so a snapshot and a test start no browser.
F7 gains `detect links in output`, default on, read per line — the same
ingest-time family as `strip incoming colour` and `emoji substitution`,
so unticking it stops the next line rather than rewriting history.
New `--view links` renders the defect: a split, so the pane is narrower
than the terminal, with one underlined span running to the pane's edge
and continuing on the next row.
Build clean, 0 warnings. Core 869, Tui 1550, Graphics 83, Scripting 42,
Web 37 — all passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Automatic HTTP(S) URL detection now applies to styled output, spawned lines, and demo output. Users can control detection from Text & ANSI settings. Pane links route internally in the web view and externally elsewhere through validated browser launching.

Changes

URL Detection and Link Routing

Layer / File(s)Summary
URL detection and styled-line rebuilding
src/SharpMUTerm.Core/Text/UrlDetector.cs, tests/SharpMUTerm.Core.Tests/Text/UrlDetectorTests.cs
Added HTTP(S) URL detection with boundary checks, punctuation trimming, length limits, overlap handling, and preservation of text and styles.
Output processing and link preference
src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs, src/SharpMUTerm.Core/Session/WorldSession.cs, src/SharpMUTerm.Tui/OptionsScreenRenderer.cs, tests/SharpMUTerm.Tui.Tests/Screen*Tests.cs
Added the enabled-by-default DetectLinks setting and applied it to processed output and spawned lines.
External browser routing and application wiring
src/SharpMUTerm.Tui/ExternalBrowser.cs, src/SharpMUTerm.Tui/SharpMUTermApp.cs, src/SharpMUTerm.Tui/Program.cs, tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs
Added HTTP(S) validation and injected desktop browser launching. Web-pane links remain internal; other pane links use the external opener.
Demo views and end-to-end coverage
src/SharpMUTerm.Tui/SharpMUTermApp.cs, tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs, tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs, CLAUDE.md
Applied detection to demo output, added the links snapshot view, exposed wrapped link spans, and added end-to-end coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Session as WorldSession
participant Detector as UrlDetector
participant App as SharpMUTermApp
participant Browser as ExternalBrowser
Session->>Detector: ApplyToLine displayed output
Detector-->>Session: Styled line with link interactions
Session->>App: Render linked pane output
App->>Browser: Validate clicked HTTP(S) target
Browser-->>App: Return canonical URI
App->>Browser: Open validated URI
Loading

Possibly related PRs

  • SharpMUSH/SharpMUTerm#7: Provides the existing TextSettings, F7 options wiring, and SharpMUTermApp behavior extended by this change.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 56.60% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: making printed URLs clickable, including URLs that wrap across rows.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/SharpMUTerm.Tui/ExternalBrowser.cs`:
- Around line 53-54: Update ExternalBrowser.Open to validate and canonicalize
the supplied URL internally before launching it, rather than passing the raw
string to ProcessStartInfo. Reuse the existing URL validation scheme gate,
launch only the resulting Uri.AbsoluteUri, and preserve disposal of the started
process.
In `@tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs`:
- Around line 318-326: Update the XML summary for LinksOn to state that
MarkupParser.Parse parses a single buffered line without pane-width wrapping and
returns its link spans. Distinguish it from PaneRowLinks, which applies the pane
width and returns the rows actually painted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c170f5a6-712d-4424-8532-7b19e8101c17

📥 Commits

Reviewing files that changed from the base of the PR and between 79fd7ec and ee679f7.

📒 Files selected for processing (14)
  • CLAUDE.md
  • src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs
  • src/SharpMUTerm.Core/Session/WorldSession.cs
  • src/SharpMUTerm.Core/Text/UrlDetector.cs
  • src/SharpMUTerm.Tui/ExternalBrowser.cs
  • src/SharpMUTerm.Tui/OptionsScreenRenderer.cs
  • src/SharpMUTerm.Tui/Program.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • tests/SharpMUTerm.Core.Tests/Text/UrlDetectorTests.cs
  • tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs
  • tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenCursorTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs
  • tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs

Comment threadsrc/SharpMUTerm.Tui/ExternalBrowser.cs Outdated
Comment threadtests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs
`ExternalBrowser.Open` took whatever it was handed and gave it to
ProcessStartInfo. The one caller validates first, so nothing was reachable
— but a scheme gate one function away from the process launch is a gate the
next caller walks around without noticing. It is now a fact about the
function: Open re-parses, launches only the resulting AbsoluteUri, and
throws otherwise, which the app already catches and reports.
Also corrects the AutoLinkTests helper comment: `LinksOn` parses a buffered
line at no particular width, so it answers "is this clickable at all";
`PaneRowLinks` parses at the pane's width and answers "can a click reach
it". Conflating those two is precisely the bug the wrapped-URL test exists
to catch, and the comment claimed the helper did the second one.
Both from CodeRabbit on #23. Core 869, Tui 1554, Graphics 83, Scripting 42,
Web 37 — all passing, build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
@HarryCordewener

Copy link
Copy Markdown
MemberAuthor

Both addressed in 0ed553b.

ExternalBrowser.Open — agreed, and the reasoning is worth stating: the gate is now enforced inside the launcher, so it holds whoever calls it and however the call site is refactored later. It re-parses, launches only Uri.AbsoluteUri, and throws ArgumentException otherwise — thrown rather than ignored, because the app already catches and reports it, and a launcher that silently did nothing would be indistinguishable from a desktop with no browser registered. Pinned by TheLauncherItselfRefusesAnythingButHttp (refusing half only, for the obvious reason).

LinksOn doc comment — correct, the comment described PaneRowLinks. Rewritten to draw the distinction it was blurring: LinksOn parses a buffered line at no width and answers is this clickable at all; PaneRowLinks parses at the pane's width and answers can a click reach it. That conflation is the exact bug the wrapped-URL test exists to catch, so the comment now says so.

@coderabbitaicoderabbitaiBot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs (1)

236-283: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Default the harness to a null browser action.

Connected injects opened.Add when callers omit withOpener. Test applications must receive a null browser action by default. Set withOpener to false. Pass withOpener: true only in tests that assert a successful external launch.

Proposed fix
- bool withOpener = true,+ bool withOpener = false,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs` around lines 236 - 283, Update
the Connected test helper’s withOpener default to false so omitted callers
inject a null browser action. Preserve the opened.Add callback only when
withOpener is explicitly true, and update successful external-launch tests to
pass withOpener: true.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs`:
- Around line 236-283: Update the Connected test helper’s withOpener default to
false so omitted callers inject a null browser action. Preserve the opened.Add
callback only when withOpener is explicitly true, and update successful
external-launch tests to pass withOpener: true.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dce52e7e-8db5-4a02-99e9-b7eb9e3d5d29

📥 Commits

Reviewing files that changed from the base of the PR and between ee679f7 and 0ed553b.

📒 Files selected for processing (2)
  • src/SharpMUTerm.Tui/ExternalBrowser.cs
  • tests/SharpMUTerm.Tui.Tests/AutoLinkTests.cs

@HarryCordewener

Copy link
Copy Markdown
MemberAuthor

Checked, and declining this one with a reason.

The premise is that a test app could launch a real browser. It cannot: the only wiring of the real launcher in the repository is Program.cs:132 (openUrl: ExternalBrowser.Open), and every opener a test injects is a List<string>.AddAutoLinkTests passes opened.Add, LinkSchemeSecurityTests passes a caller-supplied capture. No test path reaches Process.Start, so withOpener: true by default costs nothing and flipping it would put withOpener: true on eight of the ten tests that use the harness, which is noise around a hazard that does not exist here.

The property being asked for does already hold where it matters, and is pinned: AnAppWithNoOpenerLaunchesNothingAndSaysSo covers the null case, and as of 0ed553bExternalBrowser.Open enforces the scheme gate itself rather than trusting its caller — which is the defence-in-depth version of this concern, applied at the function that actually starts a process.

@HarryCordewener
HarryCordewener merged commit 3920e4e into mainAug 11, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/auto-linkify-urls branch August 12, 2026 18:33
Sign up for freeto 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

@HarryCordewener