Anchor OSC 8 links and OSC 133 marks to the buffer - #45
Merged
Conversation
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>
This was referenced 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>
Contributor
There was a problem hiding this comment.
🟡 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) onBufferLine. - Update print paths to record hyperlink spans and OSC 133 handling to record marks at the current line/column.
- Add
Terminal.TryFindPreviousPrompt/TryFindNextPromptplus 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.
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
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
OSC 8 and OSC 133 both parse today, and both are stored as a current value plus an event:
Terminal.CurrentHyperlinkis the URL in force,ShellIntegrationStateis 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.Extendedis 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 onBufferCelltakes 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,
IsReferenceOrContainsReferencesstill 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 asint?and null where none was reported, which is not the same as zero: onlyDcarries 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 bareD.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 theAmark 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/TryFindNextPromptare 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 --hyperlinkover 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.
Printwrites 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.
NoteLinkRunis 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: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 readingline.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