Skip to content

Anchor OSC 8 links and OSC 133 marks to the buffer - #45

Merged
tomlm merged 5 commits into
tomlm:mainfrom
JohnCampionJr:osc-anchoring
Aug 28, 2026
Merged

Anchor OSC 8 links and OSC 133 marks to the buffer#45
tomlm merged 5 commits into
tomlm:mainfrom
JohnCampionJr:osc-anchoring

Conversation

@JohnCampionJr

@JohnCampionJr JohnCampionJr commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

OSC 8 and OSC 133 both parse today, and both are stored as a current value plus an event: Terminal.CurrentHyperlink is the URL in force, ShellIntegrationState is the last mark seen. The events say something happened. Neither says where — and every use of either is a question about a position: hit-testing a click, jumping to the previous prompt, selecting a command's output, putting an exit status beside the command that produced it.

So the information arrives and is thrown away. This anchors both halves.

Where it goes, and why not in the cell

To the line, following what #34 did for pictures.

An id in the cell is the obvious alternative and it is closed off twice over. AttributeData.Extended is fully allocated — bits 0–8 flags, 9–11 underline style, 12–31 the interned underline colour from #36 took the last of it. And a new field on BufferCell takes it from 24 bytes to 32, measured at 22% on scroll-heavy output in #38.

The line costs nothing and already has the properties both need: it survives scroll and reflow by being moved rather than copied, and releases what it holds when it falls out of the scrollback. Null on every line that has neither, which is nearly all of them.

The cell is untouched — still 24 bytes, IsReferenceOrContainsReferences still false, and #42's layout test says so on every build.

Marks

Recorded on the line they were emitted on, with the column and, for D, the exit status. Kept as int? and null where none was reported, which is not the same as zero: only D carries a status and it is optional even there, since cmd.exe cannot read the previous command's status from its prompt and always sends a bare D.

Two decisions that would have failed silently:

Erasing does not take a mark with it. A mark records a position in the history, not anything about the content there. A shell redrawing its prompt with EL — which is most of them — would otherwise destroy the A mark it had just emitted, a moment before the prompt it marks is even printed. Intermittent-looking, not broken-looking.

Line reuse does. The ring hands back the object it is about to drop, so a mark left on it comes back as history that never happened. Same trap the placement work hit in #38.

TryFindPreviousPrompt / TryFindNextPrompt are here rather than in every host, because it is the same walk each time with one off-by-one worth getting right once: the search is strictly past the row given, so calling it from its own answer walks through the history instead of sticking on the prompt it just found.

Links

The half that touches the print path, so it is worth saying up front that it costs nothing measurable — checked rather than asserted, below.

The URL is a string on the line, not interned. Interning is free only when the set is bounded, and cluster text is: a terminal sees a handful of distinct emoji sequences in a session. URLs are not — ls --hyperlink over a large directory emits one per file — so a table nothing is ever released from would be a leak that grows with output. The line owns it, exactly as it owns its images.

Three writers keep the bookkeeping, and two are the ones that get missed. Print writes a cell at a time; both batched run writers bypass it entirely. Without those two, a link covers the text or does not depending on which writer took it. Removing them fails eight of the twelve tests, including one that writes the same input through both paths and compares, and one through the byte entry.

Guarded at the call as well as inside. NoteLinkRun is too big to inline, so an unguarded call would have every printed character pay one to be told there is no link and never has been — the shape that cost the alt-redraw corpus 12% earlier in this project.

Text written over a link is not part of it. A write takes those columns out of whatever covered them, splitting a link written through the middle — which a client can produce with nothing but a cursor move.

Measured

Against main, three runs of each alternating, 200M characters per corpus, through the harness from #42:

corpus bytes/char ns/char Δ
scroll-ascii 0.00 → 0.00 1.68 → 1.68 +0.5%
sgr-churn 2.33 → 2.33 4.41 → 4.37 −0.8%
truecolor 1.72 → 1.72 4.50 → 4.58 +1.7%
alt-redraw 0.26 → 0.26 5.53 → 5.57 +0.7%
unicode 7.66 → 7.66 12.75 → 12.69 −0.5%
flood 0.00 → 0.00 41.46 → 42.09 +1.5%

Allocation is byte-identical on every corpus — the half that is a count rather than a measurement, and so immune to what else the machine was doing. Timing deltas are all inside the noise the job observed in itself.

Tests

1179 pass, twenty-two new. Each guard was checked by removing it and watching the right tests fail: the mark recycling one drops a single test, the batched link bookkeeping drops eight.

What this unblocks

Everything left on that roadmap row is now a consumer of this, and none of it belongs in the emulator: hit-testing a link is line.TryGetLinkAt(column); jump-to-prompt is the two helpers above; selecting a command's output and an exit status in the gutter are a host reading line.Marks.

Worth noting the OSC 133 half stopped being theoretical today — before the shell-integration work, stock bash and zsh emitted no marks at all, so there was nothing to anchor.

🤖 Generated with Claude Code

JohnCampionJr and others added 2 commits August 27, 2026 20:54
The mark events say a mark happened. They cannot say WHERE, and every use of shell
integration is a question about a position: jump to the previous prompt, select a
command's output, put an exit status beside the command that produced it. So the
information arrives and is thrown away.

A mark is now recorded on the line it was emitted on, with its column and, for D, the
exit status it reported. Kept as null where none was reported, which is not the same
as zero -- only D carries a status and it is optional even there, since cmd.exe
cannot read the previous command's status from its prompt and always sends a bare D.

Anchored to the LINE rather than the cell, following what tomlm#34 did for pictures. A
cell is a struct, so a copy of one cannot say which line it came from; and the cell is
24 bytes and reference-free, which one more field would cost about 22% on
scroll-heavy output. The line already survives scroll and reflow by being moved
rather than copied, and already releases what it holds when it falls out of the
scrollback -- which is exactly what a mark wants. Verified the cell is untouched: 24
bytes, IsReferenceOrContainsReferences still false.

Two decisions worth stating, because both are the kind that fail silently.

Erasing does NOT take a mark with it. A mark records a position in the history rather
than anything about the content there, and a shell redrawing its prompt with EL --
which is most of them -- would otherwise destroy the A mark it had just emitted, a
moment before the prompt it marks is even printed. That would have made the whole
feature look intermittent rather than broken.

Line reuse DOES. The ring hands back the object it is about to drop, so a mark left
on it reappears as history that never happened -- a prompt marked in the middle of a
program's output. Same trap the placement work hit; ResetInPlace clears both.

TryFindPreviousPrompt and TryFindNextPrompt are here rather than in every host,
because it is the same walk each time with an off-by-one worth getting right once:
the search is strictly past the row given, so calling it from its own answer walks
through the history instead of sticking on the prompt it just found.

Ten tests. The recycling one fails without the ClearMarks; the erase one is a guard
against the opposite change rather than a regression test, and is labelled as such.

1167 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other half. A link is in force while cells are printed, so unlike a mark this one
touches the print path -- which is why it is worth saying up front that it costs
nothing measurable, checked rather than asserted.

The URL is held on the line as a string rather than interned the way cluster text is.
Interning is free only when the set is bounded, and cluster text is: a terminal sees
a handful of distinct emoji sequences in a session. URLs are not -- `ls --hyperlink`
over a large directory emits one per file -- so a table nothing is released from would
be a leak that grows with output. The line owns the string and it goes when the line
does, which is the same arrangement the images already have. The cell array is
untouched: still 24 bytes, still reference-free.

Three writers have to keep the bookkeeping, and two of them are the ones that always
get missed. Print writes a cell at a time; both batched run writers bypass it
entirely. Without the two batched cases a link covers the text or does not depending
on which writer happened to take it, which reads as an intermittent fault rather than
a missing case -- removing them fails eight of the twelve tests, including one that
writes the same input through both paths and compares.

Guarded at the call as well as inside: NoteLinkRun is too big to inline, so an
unguarded call would have every printed character pay one to be told there is no link
and never has been. That is the shape that cost the alt-redraw corpus 12% earlier in
this project.

Text written over a link is not part of it, so a write takes those columns out of
whatever covered them -- splitting a link that was written through the middle, which
is a case a client can produce with nothing but a cursor move.

Measured against main, three runs of each alternating, at 200M characters per corpus:

  corpus         bytes/char        ns/char      delta
  scroll-ascii   0.00 -> 0.00    1.68 -> 1.68   +0.5%
  sgr-churn      2.33 -> 2.33    4.41 -> 4.37   -0.8%
  truecolor      1.72 -> 1.72    4.50 -> 4.58   +1.7%
  alt-redraw     0.26 -> 0.26    5.53 -> 5.57   +0.7%
  unicode        7.66 -> 7.66   12.75 -> 12.69  -0.5%
  flood          0.00 -> 0.00   41.46 -> 42.09  +1.5%

Allocation is byte-identical on every corpus, which is the half that is a count rather
than a measurement. The timing deltas are all inside the noise the job observed in
itself.

1179 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JohnCampionJr JohnCampionJr changed the title Anchor OSC 133 marks to the lines they were emitted on Anchor OSC 8 links and OSC 133 marks to the buffer Aug 28, 2026
The perf gate on tomlm#47 flagged alt-redraw at +6.0% against a 6.0% gate with 2% noise,
and it was right. NoteLink carried a remark saying "guarded at the CALL as well as
inside" -- and the three call sites were bare. The guard lived only inside the
helper, so every printed character and every batched chunk paid a call to be told
there is no link and never has been. The exact shape that cost alt-redraw 12% in
the placeholder work, caught this time by the CI gate that was calibrated on it.

The three writers now test `_linkUrl is not null || line.HasLinks` themselves and
call NoteLinkRun directly; the helper is gone, because three guarded call sites
plus a helper whose comment lied is worse than no helper.

1191 tests pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are confirmed correctness issues in hyperlink bookkeeping (ID reset, ordering invariants, and erase consistency) and integer-overflow edge cases in the new prompt-search APIs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR anchors OSC 8 hyperlink spans and OSC 133 shell-integration marks onto BufferLine so consumers can query where links/marks occurred (hit-testing, prompt navigation, etc.) instead of relying on “current value + event” state only.

Changes:

  • Add per-line storage for OSC 133 marks (LineMark) and OSC 8 hyperlink spans (LineHyperlink) on BufferLine.
  • Update print paths to record hyperlink spans and OSC 133 handling to record marks at the current line/column.
  • Add Terminal.TryFindPreviousPrompt / TryFindNextPrompt plus new unit tests for marks and links anchoring.
File summaries
File Description
src/XTerm.NET/Terminal.cs Adds prompt-navigation helpers that scan anchored marks.
src/XTerm.NET/InputHandler.cs Records OSC 133 marks onto the current line and records hyperlink spans during printing.
src/XTerm.NET/Buffer/LineMark.cs New value type representing an anchored OSC 133 mark (column/kind/exit).
src/XTerm.NET/Buffer/LineHyperlink.cs New value type representing an anchored OSC 8 hyperlink span (column/len/url/id).
src/XTerm.NET/Buffer/BufferLine.cs Adds per-line collections for marks/links and hyperlink span bookkeeping logic.
src/XTerm.NET.Tests/ShellIntegrationMarkAnchorTests.cs New tests asserting OSC 133 marks are anchored and prompt navigation works.
src/XTerm.NET.Tests/HyperlinkAnchorTests.cs New tests asserting OSC 8 hyperlinks are anchored/split/joined and paths agree.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/XTerm.NET/InputHandler.cs Outdated
Comment thread src/XTerm.NET/Terminal.cs Outdated
Comment thread src/XTerm.NET/Buffer/BufferLine.cs
Comment thread src/XTerm.NET/Terminal.cs Outdated
Comment thread src/XTerm.NET/Buffer/BufferLine.cs
JohnCampionJr and others added 2 commits August 27, 2026 23:00
All five confirmed against the code before fixing.

A new link resets the id before parsing its parameters. A client can open a link
without closing the last, and one that names no id= must not inherit the previous
link's -- that would join two unrelated links into one. _linkId was reset;
Terminal.HyperlinkId was not, so the two diverged.

Erasing takes a link with the text. Fill split placements and left links alone, so
an erased span stayed clickable -- an invisible link. Deliberately unlike a mark,
and the comment says why: a mark records a position in the history and survives the
shell redrawing its prompt; a link is a property of its text.

A split keeps Links in left-to-right order. The right-hand fragment was appended,
which broke the documented ordering and the join-the-last-span optimisation in
NoteLinkRun, which relies on the last entry being the rightmost.

Both prompt walks clamp before their arithmetic. int.MinValue - 1 wraps to a huge
positive index and int.MaxValue + 1 wraps negative; either would throw out of
CircularList on input that is merely extreme, not wrong.

Four regression tests. 1183 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomlm
tomlm merged commit c3c008d into tomlm:main Aug 28, 2026
2 checks 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.

3 participants