Skip to content

feat(core): support clickable Markdown links - #1261

Draft
kitlangton wants to merge 4 commits into
anomalyco:mainfrom
kitlangton:markdown-links
Draft

feat(core): support clickable Markdown links#1261
kitlangton wants to merge 4 commits into
anomalyco:mainfrom
kitlangton:markdown-links

Conversation

@kitlangton

@kitlangton kitlangton commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What

Markdown labels and visible bare HTTP(S) URLs now carry OSC 8 terminal hyperlink metadata. Wrapped links keep one non-empty target ID across rows. Terminals without hyperlink support render the same readable text without OSC 8 bytes.

Before

OpenTUI documentation
https://example.com/a/very/
long/path

The text is readable but not clickable.

After

OpenTUI documentation                 -> https://opentui.com/docs
https://example.com/a/very/           -> https://example.com/a/very/long/path
long/path                             -> same target and OSC 8 id

OpenTUI emits OSC 8 metadata; the terminal owns interaction. macOS terminals commonly use Cmd-click, while terminals on other platforms commonly use Ctrl-click or a configured hyperlink gesture.

upstream/main at a0b90640 This branch
main at 30x18 branch at 30x18

Artifacts: main screen text, branch screen text, main raw ANSI + termctrl recording, branch raw ANSI + termctrl recording.

These captures predate the final hardening commit but remain representative of its benign visual and OSC behavior. Final-commit renderer-byte tests refresh the changed security evidence for table/image controls, entity concealment, exclusion adjacency, supported capability, unsupported capability, and wrapped IDs.

Behavior

Case Result
Public detectLinks export/signature Preserved; it mutates and returns the same chunk array
Explicit Markdown label and destination Both target Marked's parser-resolved href
& or numeric entities in a destination Decoded before target admission
Angle destination and escaped punctuation Resolved by Marked, not handwritten normalization
Bare HTTP(S) in prose Recognized by Marked 17's GFM inline parser
Uppercase HTTP:// / HTTPS:// Recognized consistently in prose and tables
ASCII sentence punctuation and balanced parentheses Follow Marked's GFM backpedaling semantics
Adjacent inline/fenced code, titles, and raw HTML Tree-sitter exclusions are hard boundaries; scanning resumes after the range
Wrapped prose or table URL Every row keeps the same non-empty target ID
Permanent-table link/image token Linked only when the centralized target admission succeeds
C0, DEL, C1, empty, or entity-decoded control target Rejected before TextChunk.link; control-bearing Marked fallback text cannot emit the attacker sequence
Concealed label entity such as [a&b](...) Every visible a&b cell remains linked
Reference/image behavior outside parser-owned support No heuristic reconstruction
Hyperlinks unsupported by the terminal Readable text remains; no OSC 8 bytes are emitted

Table images remain incidental parser-owned behavior: when Marked emits an image token its admitted target is linked. Ordinary tree-sitter rendering does not reconstruct image or reference destinations.

How

  • admitLinkTarget is the single metadata boundary for tree-sitter explicit links, tree-sitter bare links, Marked link tokens, table links, and image tokens. It decodes Markdown/HTML entities, then rejects empty targets and C0, DEL, or C1 controls.
  • detect-links.ts uses a case-insensitive scheme index only to locate small candidate regions. Marked 17's inline lexer owns GFM recognition, punctuation backpedaling, and the exact raw span; no duplicate URL grammar remains.
  • Tree-sitter raw/code/link ranges define authoritative candidate boundaries. A candidate stops at the next exclusion and scanning resumes after it.
  • Explicit destinations are passed through Marked to resolve angle syntax and escaped punctuation. No second full Markdown parser was added.
  • treeSitterToTextChunks applies source-range metadata while constructing chunks. Conceal replacements now use that same path, preserving metadata across visible entity replacements.
  • Every CodeRenderable with filetype === "markdown" enables source link detection. The public CodeOptions.detectLinks field and internal state were removed.
  • Existing native LinkPool, wrapping, cell attributes, capability handling, and OSC 8 rendering are unchanged.
flowchart LR
  A[Markdown source] --> B[tree-sitter ranges]
  A --> C[Marked 17 GFM recognition]
  B --> D[Allowed source regions]
  C --> D
  D --> E[admitLinkTarget]
  A --> F[Marked table/link/image tokens]
  F --> E
  E --> G[treeSitterToTextChunks / table chunks]
  G --> H[existing native wrapping and OSC 8 renderer]
Loading

Security

OSC 8 targets cross a terminal-control boundary. The centralized admission function decodes entities first so encoded controls cannot bypass validation, then rejects empty targets, C0 (U+0000-U+001F), DEL (U+007F), and C1 (U+0080-U+009F). This boundary is used before every new TextChunk.link metadata entry path.

The final native-byte regression sends a BEL + ESC OSC sequence through a bare table URL and entity-encoded BEL/ESC through an image token. It verifies that neither path creates an attacker target and that the attacker control sequence does not appear in renderer output. Unsupported terminals remain readable and emit no OSC 8.

This PR does not redesign or harden native LinkPool; native renderer lifecycle/frame-reset work remains out of scope.

Performance

Detector medians use 20 timed samples after 5 warmups on the same test hardware. The detector comparison is against the pre-PR implementation; times are milliseconds. The corrected URL-free rerun contains no URL at any position.

Input Size/count Before PR Final
URL-free (corrected rerun) 1024 KiB 0.671
Sparse GFM 64 / 256 / 1024 KiB 0.045 / 0.057 / 0.213 0.263 / 0.320 / 1.259
Dense GFM 64 / 256 / 1024 KiB 0.865 / 2.915 / 12.062 3.118 / 11.152 / 49.126
Dense explicit detector 1k / 2k / 4k / 8k 1.380 / 2.570 / 5.184 / 10.467 2.514 / 4.794 / 9.291 / 19.398
Growing sparse, 16 KiB appends through 1 MiB 64 cumulative rescans 7.110 42.439
Alternating )]} suffix 3k / 6k / 12k / 24k / 48k 17.863 / 69.942 / 277.712 / 1107.081 / 4424.520 0.170 / 0.339 / 0.676 / 1.380 / 2.656

The preserved public detectLinks API was measured separately around the range-application cursor change. Each generated explicit link contributes two sorted source ranges and two source-ordered chunks, one for label-${i} and one for https://target-${i}.test/path.

Public detectLinks dense explicit 1k 2k 4k 8k
Before cursor 6.029 16.551 56.895 214.228
Final cursor 2.207 4.119 8.484 18.470

The final public path remains near-linear as the fixture doubles, while the former search-from-range-zero application trends toward quadratic. The dense 1 MiB detector cost is about 50 ms on this hardware.

Real OpenCode workload sizes

Only aggregate character counts from local OpenCode histories were measured; no content is included. No prompts, text, identifiers, titles, paths, timestamps, projects, model names, or examples are included.

History Coverage Sessions represented Assistant text parts Top-level blocks Block chars p50 / p95 / p99 / p99.9 / max Blocks >=64 KiB / >=256 KiB / >=1 MiB
V2 next Full 749 7,277 43,955 37 / 446 / 1,436 / 5,823 / 12,262 0 / 0 / 0
Historical V1 dev Full 2,277 57,460 583,299 24 / 380 / 960 / 4,629 / 15,280 0 / 0 / 0
Main V1 Deterministic 10% sample by message rowid modulo 10 2,600 sampled 11,848 sampled 86,025 sampled 28 / 397 / 1,166 / 4,093 / 128,320 (sampled) 1 / 0 / 0 (sampled)

All main V1 figures are sampled; counts are not extrapolated to the full database. The full historical V1 dev scan had one 73,347-character text part at or above 64 KiB, but Markdown split it into smaller top-level blocks.

These are detector-only local medians over persisted final text, not full renderer timings and not a reconstruction of every streaming snapshot.

Persisted-final-text replay Blocks Chars URLs Detector median p95
One active long V2 session, processed together 464 59,471 58 0.73 ms 1.02 ms
Largest real V2 block 1 12,262 0 0.028 ms n/a
Most URL-dense real V2 block 1 10,465 32 0.68 ms n/a
Entire V2 history as one artificial batch 43,958 6.28M 1,006 37.7 ms n/a

A 1 MiB block is a stress-test size: none was observed in the full V2 history, the full historical V1 dev history, or the sampled main V1 rows. The main V1 sample does not prove that no 1 MiB block exists in unsampled rows. Real block p99 is around 1–1.4 KiB; the meaningful residual risk remains an unusually large, continuously growing, URL-dense block. The synthetic worst-case benchmarks remain because they establish scaling and security boundaries.

The removed benchmark can be reproduced with these exact detector fixtures (truncated to the requested byte size):

const plain = "ordinary markdown text without a link\n"
const linked = "ordinary markdown https://example.com/path?q=1 text\n"
const fit = (line: string, size: number) => line.repeat(Math.ceil(size / line.length)).slice(0, size)

const urlFree = fit(plain, 1024 * 1024)
const dense = fit(linked, 1024 * 1024)

The former “URL-free” generator selected line % Infinity === 0; line zero therefore inserted one URL. Sparse and growing fixtures use the same line generator with frequency 100. The growing result is the cumulative cost of rescanning one single growing block at every 16 KiB snapshot through 1 MiB: 64 scans of content.slice(0, end). Each snapshot is linear, but their cumulative work remains quadratic across snapshots. Normal incremental Markdown usually reuses stable blocks and limits rescans to trailing unstable blocks.

Parser-owned GFM recognition has a measurable constant-factor cost while remaining sub-millisecond for genuinely URL-free 1 MiB and near-linear for static sparse, dense, and explicit inputs. The adversarial suffix path changes from roughly 4x time per 2x input to roughly 2x, and 48k falls from 4.42 seconds to 2.66 milliseconds. Residual risk remains for very large, continuously growing URL-dense blocks because the trailing unstable block must still be rescanned.

Scope

Simplify

The final simplify pass:

  • removed the custom URL punctuation, possessive, Unicode, and repeated delimiter-balancing grammar
  • removed the quadratic suffix trimmer
  • removed CodeOptions.detectLinks and its internal state
  • removed the standalone benchmark and package script
  • replaced public detectLinks range-zero searches with a monotonic cursor
  • reduced the renderer-byte suite from 268 to 158 lines without broader test infrastructure
  • kept the final implementation and compatibility coverage to the same 10-file surface (+471/-179)

Final line split: production/config +196/-102; tests +275/-77. No reusable existing renderer-byte utility or entity decoder existed; entities is now an explicit runtime dependency for complete named/numeric entity decoding.

Testing

  • Broader affected tree-sitter/Code/Markdown suites: 285 passed, 1 existing skip
  • Final detector + native-byte suites: 14 passed
  • bun run test:js: 4,987 passed, 23 existing skips, 0 failed
  • bun run build:lib: passed, including declarations and copied tree-sitter queries
  • bun run lint:ci: passed with zero warnings
  • bun run fmt:check: passed
  • git diff --check: passed
  • Public detectLinks 1k/2k/4k/8k before/after cursor matrix plus corrected URL-free and dense 1 MiB detector reruns: final medians above
  • bun run test:native: attempted with repository-pinned Zig 0.15.2; this macOS/Xcode host hits the Zig build-runner linker failure (__availability_version_check, _abort, and other libSystem symbols) before repository code compiles
  • bun run test:dist: blocked at its native build by the same Zig linker failure; build:lib passes
  • Root aggregate package test: other package suites ran, but the aggregate exits nonzero because core starts with unavailable PATH Zig and the unrelated SSH package expects a prebuilt packages/ssh/dist/package.json

The 158-line renderer-byte suite drives real Markdown through tree-sitter/Marked, TextChunk, native text buffering/wrapping, capability handling, NativeSpanFeed, and captured stdout. It asserts exact non-empty IDs and targets rather than accepting arbitrary strings.

@kitlangton
kitlangton force-pushed the markdown-links branch 2 times, most recently from 17ea52b to 0d13c28 Compare July 12, 2026 03:40
@kommander

Copy link
Copy Markdown
Collaborator

Reworked this after tracing the actual failure path and measuring the complete conversion/native pipeline.

  • Markdown link detection now runs through MarkdownRenderable's onHighlight hook and supplies exact source linkRanges; generic CodeRenderable/tree-sitter code no longer hardcodes Markdown or detects links itself. The fuzzy public detectLinks post-pass was removed because using it with the built-in path performed duplicate, non-idempotent work and could link unrelated concealed text.
  • Replaced the per-call pushChunk closure with a module-level range writer and preserved the direct no-link path.
  • Fixed semicolonless/escaped ampersands being rewritten, escaped ! being mistaken for an image marker, control-target admission, range overlap text duplication, and OSC 8 test parsing for targets containing semicolons.
  • Added bench:markdown-links, measuring conversion and TextBuffer.setStyledText. Versus the prior PR implementation: dense bare 16 KiB conversion 0.520 -> 0.455 ms and native 0.739 -> 0.652 ms; dense explicit 256 KiB conversion 15.33 -> 13.76 ms and native 22.36 -> 20.36 ms. The old parent post-pass took 1508 ms for that 256 KiB explicit case. A measured 3.5% exclusion-sort optimization was rejected.
  • Merged latest live/main (f26eb147d) and updated the Code/Markdown docs.

Verification: 5127 Bun tests passed, 1697 native tests passed, 4458 Node 26.4 tests passed, packed-dist tests passed, and build/lint/format passed.

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.

2 participants