Skip to content

Fixes #5275. Clear stale TextView OSC 8 hyperlinks#5276

Draft
harder wants to merge 4 commits into
gui-cs:developfrom
harder:harder/issue-5275-textview-osc8
Draft

Fixes #5275. Clear stale TextView OSC 8 hyperlinks#5276
harder wants to merge 4 commits into
gui-cs:developfrom
harder:harder/issue-5275-textview-osc8

Conversation

@harder
Copy link
Copy Markdown
Collaborator

@harder harder commented May 8, 2026

Summary

Fixes the stale OSC 8 hyperlink state in TextView when links come from plain-text URL auto-detection rather than explicit Link views.

Changes

  • Split output-buffer URL tracking so explicit links and auto-detected plain-text URLs are synchronized independently.
  • Reused Osc8UrlLinker URL-range detection to map auto-detected URLs into per-cell output state before rendering.
  • Updated row rendering to emit OSC 8 close sequences when a row previously had hyperlinks but no longer does after redraw, scroll, or delete-all.
  • Added regression coverage for overwriting auto-detected URLs with plain text and spaces.
  • Preserved the explicit-link path fixed by Fixes #4892. OSC 8 hyperlink rendering does not clean up #5254; this addresses a separate pre-existing bug in the plain-text auto-linking path.

Testing

  • dotnet build --no-restore
  • dotnet test --project Tests/UnitTestsParallelizable --no-build

To pull down this PR locally:

git remote add copilot https://github.com/harder/Terminal.Gui.git
git fetch copilot harder/issue-5275-textview-osc8
git checkout copilot/harder/issue-5275-textview-osc8

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@harder
Copy link
Copy Markdown
Collaborator Author

harder commented May 8, 2026

Code review

Found 3 issues:

  1. Auto-URL detection treats raw char offsets as column indices, which breaks for any multi-codepoint grapheme cluster (.claude/rules/unicode-graphemes.md says "Think in graphemes, not runes" and warns against treating char counts as terminal cells).

SyncAutoUrlsForRowCore builds rowText by appending each cell's Grapheme — that string can be 1 char ("a"), 2 chars ("é" = 'e' + U+0301), or 8+ chars (ZWJ emoji like 👨‍👩‍👦). It then passes the concatenation to FindUrls and uses range.Start directly as a column index. For a row like 🚀 https://example.com, the URL's char offset in rowText diverges from its column position, so _autoUrlMap tags the wrong cells and the link visibly drifts off the URL. Test coverage uses ASCII so this is invisible to the new tests.

StringBuilder rowText = new (Cols);
for (int col = 0; col < Cols; col++)
{
string grapheme = Contents [row, col].Grapheme;
rowText.Append (string.IsNullOrEmpty (grapheme) ? " " : grapheme);
}
foreach (Osc8UrlLinker.UrlRange range in Osc8UrlLinker.FindUrls (rowText.ToString ()))
{
int end = Math.Min (range.Start + range.Length, Cols);
for (int col = range.Start; col < end; col++)
{
_autoUrlMap ??= [];
_autoUrlMap [new Point (col, row)] = range.Url;
}
}
}

  1. _rowsWithUrls (on OutputBase) is never cleared when the buffer is reset, so stale row indices survive across ClearContents/SetSize.

OutputBufferImpl.ClearContentsCore clears both URL maps, but OutputBase._rowsWithUrls lives in a different class and is not notified. After a resize or clear, the next render sees rowHadUrlsPreviously = true for stale rows; if the new content has no URLs there, a spurious OSC_EndHyperlink() is prepended at the start of those rows. Mostly cosmetic, but a real correctness drift between the two structures — consider exposing a ClearUrlState() on OutputBase that callers invoke from ClearContents/SetSize, or computing rowHadUrlsPreviously from the buffer's maps directly.

DirtyLines = new bool [Rows];
_explicitUrlMap?.Clear ();
_autoUrlMap?.Clear ();
for (var row = 0; row < Rows; row++)

  1. The GetCellUrl fast-path is defeated for the lifetime of the buffer once any URL is ever set (a Copilot reviewer flagged the same anti-pattern in Fixes #5193. Emit OSC 8 hyperlink sequences in Driver.ToAnsi() #5195).

ClearContentsCore calls .Clear() on both maps but does not null them, so after the first URL is ever assigned both _explicitUrlMap and _autoUrlMap stay non-null forever. The null fast-path in GetCellUrl then always misses, and the newly added RowContainsUrls adds another full-row scan per dirty row — roughly an extra 1920 lock acquisitions per frame on a 24×80 buffer with no URLs. Setting the maps to null in ClearContentsCore (and after SyncAutoUrlsForRowCore finds zero matches, where applicable) restores the optimization.

public string? GetCellUrl (int col, int row)
{
if (_explicitUrlMap is null && _autoUrlMap is null)
{
return null;
}
lock (_contentsLock)
{
Point point = new (col, row);
if (_explicitUrlMap?.TryGetValue (point, out string? explicitUrl) == true)
{
return explicitUrl;
}
return _autoUrlMap?.TryGetValue (point, out string? autoUrl) == true ? autoUrl : null;
}
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

harder and others added 3 commits May 8, 2026 19:27
- Map char offsets back to columns in SyncAutoUrlsForRowCore so that
  multi-codepoint graphemes (ZWJ emoji, base + combining mark) before a
  URL no longer shift the auto-link metadata onto the wrong cells.
- Null out _explicitUrlMap and _autoUrlMap in ClearContentsCore (and when
  SyncAutoUrlsForRowCore leaves _autoUrlMap empty) so GetCellUrl's null
  fast-path stays effective and RowContainsUrls doesn't acquire the
  contents lock per cell on URL-free buffers. Bump a UrlStateVersion
  counter so OutputBase can detect resets.
- Track buffer reference, dimensions, and UrlStateVersion in OutputBase;
  drop _rowsWithUrls when any of those change so resize/clear no longer
  leaves stale row indices that would emit a spurious OSC 8 close at the
  start of the next render.
- Refresh the Write XML summary that still mentioned WrapOsc8.
- Add regression tests for the grapheme alignment, post-resize state,
  and GetCellUrl fast-path restoration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…acking

The condition added in 56eef0c ("emit OSC 8 close at row start whenever
the row had URLs previously") was a defensive workaround for the apparent
floating-underline symptom on Warp/Windows Terminal. Investigation of WT
source shows the bug is in ControlCore::_updateHoveredCell()'s stale
_lastHoveredCell cache (no callback fires when buffer content changes
under a stationary cursor) — not in Terminal.Gui's emission. The cells
themselves get _hyperlinkId = 0 correctly via AdaptDispatch::EndHyperlink
when we emit `OSC 8 ; ; ST` followed by new cell content.

Restoring the original (rowHadUrlsPreviously && \!rowHasUrlsNow) condition
avoids a redundant escape on every row that still contains a URL after
redraw. Also drops the regression test that pinned the redundant behavior.

Separately, fix a real bookkeeping bug: _rowsWithUrls.Add/Remove was
placed after the empty-builder early-exit at the end of the per-row block.
Rows whose dirty cells were entirely flushed mid-loop via WriteToConsole
(leaving the builder empty and _lastUrl null) skipped the row-tracking
update, leaving stale entries that trigger spurious row-start OSC 8 closes
on subsequent frames. Move the Add/Remove before the early-exit, and add
a regression test that fails without the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@harder
Copy link
Copy Markdown
Collaborator Author

harder commented May 11, 2026

I've extensively tested with multiple agents and models trying various angles to fix the two root issues observed in #5275:

  1. After hovering a link and scrolling with the mouse wheel, the underline can appear on the wrong text and move with the mouse cursor instead of staying on the actual link.
  2. After selecting all text and deleting it, the deleted links can still appear hoverable at their former screen locations.

They did find and fix several small bugs related to OSC 8 handling that should make Terminal.Gui as compliant as possible. But were not able to fix the root issue, and after testing with various terminals and AI agent analysis of the Windows.Terminal codebase, I believe the root cause is a Windows Terminal bug.

Observations:

  1. In Windows Terminal in Windows 11 on my Surface ARM-based laptop, I still see both issues above (1 and 2).
  2. In Warp terminal on MacOS, I see issue 1 above (have never seen issue 2). Claude found a known issue in Warp that explains issue 1, and Warp does it's own custom link regex parsing that prevents it from being affected by issue 2.
  3. In iTerm2 terminal on MacOS, I cannot reproduce either issue. iTerm2 is known for its reference-quality OSC8 support.

If anyone has other terminals they can test with, I'd appreciate it.

I am going to file a bug report in the Microsoft Terminal project with repro details. The other OSC8 related bug fixes in this PR can be reviewed and merged.

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.

TextView plain-text OSC 8 hyperlinks are not fully cleared after scroll or delete-all

1 participant