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/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) { }
+ }
}
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