From ab249c38b0c1f3750c6d8868000e02cc8d138bc6 Mon Sep 17 00:00:00 2001
From: Cody Mullins <1738479+codymullins@users.noreply.github.com>
Date: Tue, 7 Jul 2026 20:01:34 -0400
Subject: [PATCH 1/2] Fix ZshPromptShim.Cleanup: restore missing brace and drop
unreachable catch
main has not compiled since f4f7964: the Cleanup method lost its closing
brace, and a catch (DirectoryNotFoundException) was added after
catch (IOException), which already catches it (subtype -> unreachable).
Restore the method to its working shape.
---
Dotty.Terminal/Hosting/ZshPromptShim.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Dotty.Terminal/Hosting/ZshPromptShim.cs b/Dotty.Terminal/Hosting/ZshPromptShim.cs
index 49e2cb1..c48f068 100644
--- a/Dotty.Terminal/Hosting/ZshPromptShim.cs
+++ b/Dotty.Terminal/Hosting/ZshPromptShim.cs
@@ -95,5 +95,5 @@ internal static void Cleanup(string? shimDirectory)
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
- catch (DirectoryNotFoundException) { }
+ }
}
From 59f98915d87ed6ac93b72a53cac34a9bd394dd59 Mon Sep 17 00:00:00 2001
From: Cody Mullins <1738479+codymullins@users.noreply.github.com>
Date: Tue, 7 Jul 2026 20:01:48 -0400
Subject: [PATCH 2/2] Add OSC 8 hyperlink support
Terminals mark links with OSC 8 (ESC ] 8 ; params ; URI ST). Track them
through the emulator so a UI can hover/click them.
- Cell gains a HyperlinkId (a small id into a per-terminal URI table, kept
cheap to copy through scrollback and reflow; 0 = no link).
- Terminal carries a hyperlink pen and an id->URI table, deduped by the
OSC 8 id= param (or URI when absent) so a link wrapped across rows, or
repeated, reuses one id and the table stays bounded.
- VtHandler parses OSC 8; the OSC payload cap grows to 4096 for long URIs.
- TerminalScreenSnapshot exposes the per-cell id, the URI map, and a
HyperlinkAt(col,row) helper.
Also fixes a pre-existing bug: ST-terminated OSC (ESC \) never dispatched
because the 'anywhere' ESC transition jumped to the Escape state without
finalizing the OSC string. Only the BEL terminator worked, so OSC titles,
cwd, and now hyperlinks set via ST were silently dropped. The ESC
transition now dispatches a pending OSC first.
Adds HyperlinkTests covering parsing, id grouping, dedup, long URIs, RIS
reset, and the snapshot surface.
---
Dotty.Terminal.Tests/HyperlinkTests.cs | 130 ++++++++++++++++++
Dotty.Terminal/Cell.cs | 10 ++
Dotty.Terminal/Parser/VtHandler.cs | 36 +++++
Dotty.Terminal/Parser/VtStateMachine.cs | 21 +--
.../Rendering/TerminalScreenSnapshot.cs | 31 ++++-
Dotty.Terminal/Terminal.cs | 52 +++++++
6 files changed, 268 insertions(+), 12 deletions(-)
create mode 100644 Dotty.Terminal.Tests/HyperlinkTests.cs
diff --git a/Dotty.Terminal.Tests/HyperlinkTests.cs b/Dotty.Terminal.Tests/HyperlinkTests.cs
new file mode 100644
index 0000000..a85b6c6
--- /dev/null
+++ b/Dotty.Terminal.Tests/HyperlinkTests.cs
@@ -0,0 +1,130 @@
+using System.Text;
+using Dotty.Terminal;
+using Dotty.Terminal.Rendering;
+using Xunit;
+
+namespace Dotty.Terminal.Tests;
+
+public class HyperlinkTests
+{
+ private static Terminal Feed(params string[] chunks)
+ {
+ var term = new Terminal(new GridSize(80, 24));
+ foreach (var chunk in chunks)
+ term.ProcessPtyOutput(Encoding.UTF8.GetBytes(chunk));
+ return term;
+ }
+
+ // OSC 8 hyperlink: ESC ] 8 ; params ; URI ST (ST = ESC \). An empty URI closes.
+ private static string Open(string uri, string? id = null) =>
+ $"\x1b]8;{(id is null ? "" : $"id={id}")};{uri}\x1b\\";
+
+ private static readonly string Close = "\x1b]8;;\x1b\\";
+
+ [Fact]
+ public void LinkedCellsShareOneIdAndResolveToUri()
+ {
+ var term = Feed(Open("https://example.com"), "link", Close, "x");
+
+ var row = term.RowCells(0);
+ var id = row[0].HyperlinkId;
+
+ Assert.NotEqual(0, id);
+ Assert.Equal(id, row[1].HyperlinkId);
+ Assert.Equal(id, row[2].HyperlinkId);
+ Assert.Equal(id, row[3].HyperlinkId);
+ Assert.Equal("https://example.com", term.Hyperlinks[id]);
+
+ // Text after the close carries no link.
+ Assert.Equal(0, row[4].HyperlinkId);
+ }
+
+ [Fact]
+ public void PlainTextHasNoLink()
+ {
+ var term = Feed("plain text");
+
+ Assert.Equal(0, term.RowCells(0)[0].HyperlinkId);
+ Assert.Empty(term.Hyperlinks);
+ }
+
+ [Fact]
+ public void EmptyUriClosesTheCurrentLink()
+ {
+ var term = Feed(Open("https://a.test"), "A", Close, "B");
+
+ var row = term.RowCells(0);
+ Assert.NotEqual(0, row[0].HyperlinkId); // A
+ Assert.Equal(0, row[1].HyperlinkId); // B
+ }
+
+ [Fact]
+ public void ExplicitIdGroupsSpansAcrossRows()
+ {
+ // Same id= on two spans (a link split across a newline) => one link id.
+ var term = Feed(
+ Open("https://wrapped.test", id: "L1"), "AA", Close,
+ "\r\n",
+ Open("https://wrapped.test", id: "L1"), "BB", Close);
+
+ var first = term.RowCells(0)[0].HyperlinkId;
+ var second = term.RowCells(1)[0].HyperlinkId;
+
+ Assert.NotEqual(0, first);
+ Assert.Equal(first, second);
+ Assert.Single(term.Hyperlinks);
+ }
+
+ [Fact]
+ public void SameUriWithoutIdIsDeduplicated()
+ {
+ var term = Feed(
+ Open("https://dup.test"), "A", Close,
+ Open("https://dup.test"), "B", Close);
+
+ Assert.Single(term.Hyperlinks);
+ }
+
+ [Fact]
+ public void LongUriWithinBufferCapIsStored()
+ {
+ var uri = "https://example.com/" + new string('a', 2000);
+ var term = Feed(Open(uri), "x", Close);
+
+ var id = term.RowCells(0)[0].HyperlinkId;
+ Assert.Equal(uri, term.Hyperlinks[id]);
+ }
+
+ [Fact]
+ public void MalformedOsc8WithoutSeparatorIsIgnored()
+ {
+ // No second ';' — nothing to open, and it must not throw.
+ var term = Feed("\x1b]8\x1b\\", "text");
+
+ Assert.Equal(0, term.RowCells(0)[0].HyperlinkId);
+ Assert.Empty(term.Hyperlinks);
+ }
+
+ [Fact]
+ public void ResetClearsHyperlinkTable()
+ {
+ var term = Feed(Open("https://a.test"), "A", Close);
+ Assert.NotEmpty(term.Hyperlinks);
+
+ // RIS (ESC c) — full reset. Explicit bytes avoid the \x1b greedy-hex
+ // hazard where 'c' would be read as a hex digit.
+ term.ProcessPtyOutput(new byte[] { 0x1b, (byte)'c' });
+ Assert.Empty(term.Hyperlinks);
+ }
+
+ [Fact]
+ public void SnapshotExposesLinkUriPerCell()
+ {
+ var term = Feed(Open("https://snap.test"), "hi", Close, "!");
+ var snapshot = TerminalScreenSnapshot.FromTerminal(term);
+
+ Assert.Equal("https://snap.test", snapshot.HyperlinkAt(0, 0));
+ Assert.Equal("https://snap.test", snapshot.HyperlinkAt(1, 0));
+ Assert.Null(snapshot.HyperlinkAt(2, 0)); // the '!' after close
+ }
+}
diff --git a/Dotty.Terminal/Cell.cs b/Dotty.Terminal/Cell.cs
index 3b8000d..393cad8 100644
--- a/Dotty.Terminal/Cell.cs
+++ b/Dotty.Terminal/Cell.cs
@@ -23,12 +23,21 @@ public struct Cell
public Color Bg;
public CellAttributes Attrs;
+ ///
+ /// OSC 8 hyperlink reference: an id into the terminal's link table (0 = no
+ /// link). Cells printed between an OSC 8 open and close share the same id, so
+ /// a link that wraps across rows is one logical link. The id, not the URI
+ /// string, keeps a cheap value type to copy and reflow.
+ ///
+ public ushort HyperlinkId;
+
public static Cell Default => new()
{
Codepoint = ' ',
Fg = Color.DefaultColor,
Bg = Color.DefaultColor,
Attrs = CellAttributes.None,
+ HyperlinkId = 0,
};
public void Reset()
@@ -37,6 +46,7 @@ public void Reset()
Fg = Color.DefaultColor;
Bg = Color.DefaultColor;
Attrs = CellAttributes.None;
+ HyperlinkId = 0;
}
public bool IsEmpty =>
diff --git a/Dotty.Terminal/Parser/VtHandler.cs b/Dotty.Terminal/Parser/VtHandler.cs
index 8b2fe8d..162001b 100644
--- a/Dotty.Terminal/Parser/VtHandler.cs
+++ b/Dotty.Terminal/Parser/VtHandler.cs
@@ -397,12 +397,48 @@ public void OscDispatch(ReadOnlySpan payload)
case 7: // Current Working Directory
_terminal.SetWorkingDirectory(Encoding.UTF8.GetString(data));
break;
+ case 8: // Hyperlink: OSC 8 ; params ; URI ST
+ HandleOsc8(data);
+ break;
case 52: // Clipboard access
HandleOsc52(data);
break;
}
}
+ ///
+ /// Handles an OSC 8 hyperlink. The payload after the command number is
+ /// params ; URI; an empty URI closes the current link. params is
+ /// a colon-separated key=value list whose only standard key is id,
+ /// which groups spans (e.g. a link wrapped across rows) under one link.
+ ///
+ private void HandleOsc8(ReadOnlySpan data)
+ {
+ int sep = data.IndexOf((byte)';');
+ if (sep < 0)
+ return; // Malformed: no params/URI separator.
+
+ var id = ExtractHyperlinkId(data[..sep]);
+ var uri = Encoding.UTF8.GetString(data[(sep + 1)..]);
+ _terminal.SetHyperlink(id, uri);
+ }
+
+ /// Pulls the id= value out of an OSC 8 params section
+ /// (key=value:key=value), or null when absent.
+ private static string? ExtractHyperlinkId(ReadOnlySpan paramsPart)
+ {
+ if (paramsPart.Length == 0)
+ return null;
+
+ foreach (var kv in Encoding.UTF8.GetString(paramsPart).Split(':'))
+ {
+ if (kv.StartsWith("id=", StringComparison.Ordinal))
+ return kv["id=".Length..];
+ }
+
+ return null;
+ }
+
private void ParseSgr(ReadOnlySpan parameters)
{
if (parameters.Length == 0)
diff --git a/Dotty.Terminal/Parser/VtStateMachine.cs b/Dotty.Terminal/Parser/VtStateMachine.cs
index e0cd24a..752e978 100644
--- a/Dotty.Terminal/Parser/VtStateMachine.cs
+++ b/Dotty.Terminal/Parser/VtStateMachine.cs
@@ -35,8 +35,9 @@ private enum State
private readonly byte[] _intermediates = new byte[4];
private int _intermediateCount;
- // OSC payload
- private readonly byte[] _oscPayload = new byte[512];
+ // OSC payload. Sized for OSC 8 hyperlink URIs (the realistic long-OSC case);
+ // bytes past the cap are dropped rather than growing the buffer.
+ private readonly byte[] _oscPayload = new byte[4096];
private int _oscPayloadLen;
// UTF-8 decoding
@@ -78,6 +79,13 @@ private void ProcessByte(IVtHandler handler, byte b)
_state = State.Ground;
return;
case 0x1B: // ESC
+ // ESC inside an OSC string is the start of a String Terminator
+ // (ST = ESC \): finalize the OSC now, then let the trailing '\'
+ // be consumed in the Escape state. Without this, ST-terminated
+ // OSC (titles, cwd, OSC 8 hyperlinks) would never dispatch — only
+ // the BEL terminator would.
+ if (_state == State.OscString)
+ handler.OscDispatch(OscPayload);
TransitionTo(State.Escape);
return;
}
@@ -352,12 +360,9 @@ private void OscStringState(IVtHandler handler, byte b)
handler.OscDispatch(OscPayload);
_state = State.Ground;
break;
- case 0x1B:
- // ESC could be start of ST (ESC \)
- // We'll handle this by checking next byte — for simplicity, dispatch now
- handler.OscDispatch(OscPayload);
- TransitionTo(State.Escape);
- break;
+ // NB: ESC (start of a 7-bit ST) is intercepted by the "anywhere"
+ // transition in ProcessByte, which dispatches the OSC before this
+ // state ever sees it.
default:
if (_oscPayloadLen < _oscPayload.Length)
_oscPayload[_oscPayloadLen++] = b;
diff --git a/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs b/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs
index b322d87..de9cb35 100644
--- a/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs
+++ b/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs
@@ -5,10 +5,18 @@ public sealed record TerminalScreenSnapshot(
IReadOnlyList Cells,
TerminalRenderCursor Cursor,
bool IsScrolledBack,
- string? ExitMessage)
+ string? ExitMessage,
+ IReadOnlyDictionary Hyperlinks)
{
public TerminalRenderCell CellAt(ushort col, ushort row) => Cells[row * Size.Cols + col];
+ /// The OSC 8 URI a cell links to, or null when it has no link.
+ public string? HyperlinkAt(ushort col, ushort row)
+ {
+ var id = CellAt(col, row).HyperlinkId;
+ return id != 0 && Hyperlinks.TryGetValue(id, out var uri) ? uri : null;
+ }
+
public static TerminalScreenSnapshot FromTerminal(Terminal terminal)
{
var size = terminal.GridSize;
@@ -28,7 +36,8 @@ public static TerminalScreenSnapshot FromTerminal(Terminal terminal)
cell.Fg,
cell.Bg,
cell.Attrs,
- selection?.Contains(pos) ?? false);
+ selection?.Contains(pos) ?? false,
+ cell.HyperlinkId);
}
}
@@ -45,8 +54,21 @@ public static TerminalScreenSnapshot FromTerminal(Terminal terminal)
cells,
new TerminalRenderCursor(cursor.Position, cursor.Shape, cursor.Visible, cursor.Blinking),
terminal.IsScrolledBack,
- exitMessage);
+ exitMessage,
+ SnapshotHyperlinks(terminal.Hyperlinks));
}
+
+ // Copy the live link table so the snapshot stays a stable value even as the
+ // terminal keeps mutating. Empty tables share one instance (no per-frame
+ // allocation for the common no-links case).
+ private static IReadOnlyDictionary SnapshotHyperlinks(
+ IReadOnlyDictionary live) =>
+ live.Count == 0
+ ? EmptyHyperlinks
+ : new Dictionary(live);
+
+ private static readonly IReadOnlyDictionary EmptyHyperlinks =
+ new Dictionary();
}
public readonly record struct TerminalRenderCell(
@@ -55,7 +77,8 @@ public readonly record struct TerminalRenderCell(
Color Foreground,
Color Background,
CellAttributes Attributes,
- bool IsSelected);
+ bool IsSelected,
+ ushort HyperlinkId);
public readonly record struct TerminalRenderCursor(
GridPosition Position,
diff --git a/Dotty.Terminal/Terminal.cs b/Dotty.Terminal/Terminal.cs
index 8a1de08..73f06a7 100644
--- a/Dotty.Terminal/Terminal.cs
+++ b/Dotty.Terminal/Terminal.cs
@@ -23,6 +23,15 @@ public class Terminal
private Color _penBg;
private CellAttributes _penAttrs;
+ // OSC 8 hyperlinks. Cells store a small id; these map it to the URI. The pen
+ // holds the id of the currently-open link (0 = none). Links are deduplicated
+ // by their explicit `id=` param, or by URI when none is given, so a link that
+ // wraps across rows — or repeats — reuses one id and the table stays bounded.
+ private ushort _penHyperlinkId;
+ private ushort _nextHyperlinkId = 1; // 0 is reserved for "no link"
+ private readonly Dictionary _hyperlinks = new();
+ private readonly Dictionary _hyperlinkKeys = new();
+
// Selection state
private SelectionRange? _selection;
@@ -417,6 +426,7 @@ internal void PutChar(char c)
cell.Fg = _penFg;
cell.Bg = _penBg;
cell.Attrs = wide ? _penAttrs | CellAttributes.Wide : _penAttrs;
+ cell.HyperlinkId = _penHyperlinkId;
if (wide)
{
@@ -426,6 +436,7 @@ internal void PutChar(char c)
spacer.Fg = _penFg;
spacer.Bg = _penBg;
spacer.Attrs = _penAttrs | CellAttributes.WideSpacer;
+ spacer.HyperlinkId = _penHyperlinkId;
}
_damage.MarkRow(_cursor.Position.Row);
@@ -684,6 +695,10 @@ internal void Reset()
_penFg = Color.DefaultColor;
_penBg = Color.DefaultColor;
_penAttrs = CellAttributes.None;
+ _penHyperlinkId = 0;
+ _nextHyperlinkId = 1;
+ _hyperlinks.Clear();
+ _hyperlinkKeys.Clear();
_selection = null;
_title = "";
_workingDirectory = null;
@@ -720,6 +735,43 @@ internal void SetCursorVisible(bool visible)
internal void SetWorkingDirectory(string dir) => _workingDirectory = dir;
+ ///
+ /// Opens or closes an OSC 8 hyperlink. An empty closes
+ /// the current link; a non-empty one becomes the pen link for subsequent
+ /// output. is the sequence's explicit id= param
+ /// (or null); it groups spans — e.g. the two rows of a wrapped link — under
+ /// one link id. The URI is stored verbatim; callers that act on it (open a
+ /// browser) are responsible for restricting schemes.
+ ///
+ internal void SetHyperlink(string? id, string uri)
+ {
+ if (string.IsNullOrEmpty(uri))
+ {
+ _penHyperlinkId = 0;
+ return;
+ }
+
+ // Group by explicit id when present so a wrapped/multi-span link is one
+ // entry; otherwise group by URI so repeats of the same link don't grow
+ // the table without bound.
+ var key = string.IsNullOrEmpty(id) ? "u " + uri : "i " + id;
+ if (!_hyperlinkKeys.TryGetValue(key, out var linkId))
+ {
+ linkId = _nextHyperlinkId;
+ // ushort ids top out at 65535 — vanishingly unlikely in a session, but
+ // stop allocating rather than wrap to 0 ("no link") or collide.
+ if (_nextHyperlinkId != ushort.MaxValue)
+ _nextHyperlinkId++;
+ _hyperlinkKeys[key] = linkId;
+ _hyperlinks[linkId] = uri;
+ }
+
+ _penHyperlinkId = linkId;
+ }
+
+ /// OSC 8 hyperlink table: cell → URI.
+ public IReadOnlyDictionary Hyperlinks => _hyperlinks;
+
internal void PushResponse(ReadOnlySpan data) => _responseBuffer.AddRange(data);
// Bell event — UI layer can subscribe to trigger visual bell