Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions Dotty.Terminal.Tests/HyperlinkTests.cs
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 10 additions & 0 deletions Dotty.Terminal/Cell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,21 @@ public struct Cell
public Color Bg;
public CellAttributes Attrs;

/// <summary>
/// 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 <see cref="Cell"/> a cheap value type to copy and reflow.
/// </summary>
public ushort HyperlinkId;

public static Cell Default => new()
{
Codepoint = ' ',
Fg = Color.DefaultColor,
Bg = Color.DefaultColor,
Attrs = CellAttributes.None,
HyperlinkId = 0,
};

public void Reset()
Expand All @@ -37,6 +46,7 @@ public void Reset()
Fg = Color.DefaultColor;
Bg = Color.DefaultColor;
Attrs = CellAttributes.None;
HyperlinkId = 0;
}

public bool IsEmpty =>
Expand Down
2 changes: 1 addition & 1 deletion Dotty.Terminal/Hosting/ZshPromptShim.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,5 @@ internal static void Cleanup(string? shimDirectory)
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
catch (DirectoryNotFoundException) { }
}
}
36 changes: 36 additions & 0 deletions Dotty.Terminal/Parser/VtHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,48 @@ public void OscDispatch(ReadOnlySpan<byte> 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;
}
}

/// <summary>
/// Handles an OSC 8 hyperlink. The payload after the command number is
/// <c>params ; URI</c>; an empty URI closes the current link. <c>params</c> is
/// a colon-separated key=value list whose only standard key is <c>id</c>,
/// which groups spans (e.g. a link wrapped across rows) under one link.
/// </summary>
private void HandleOsc8(ReadOnlySpan<byte> 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);
}

/// <summary>Pulls the <c>id=</c> value out of an OSC 8 params section
/// (<c>key=value:key=value</c>), or null when absent.</summary>
private static string? ExtractHyperlinkId(ReadOnlySpan<byte> 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<ushort> parameters)
{
if (parameters.Length == 0)
Expand Down
21 changes: 13 additions & 8 deletions Dotty.Terminal/Parser/VtStateMachine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 27 additions & 4 deletions Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ public sealed record TerminalScreenSnapshot(
IReadOnlyList<TerminalRenderCell> Cells,
TerminalRenderCursor Cursor,
bool IsScrolledBack,
string? ExitMessage)
string? ExitMessage,
IReadOnlyDictionary<ushort, string> Hyperlinks)
{
public TerminalRenderCell CellAt(ushort col, ushort row) => Cells[row * Size.Cols + col];

/// <summary>The OSC 8 URI a cell links to, or null when it has no link.</summary>
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;
Expand All @@ -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);
}
}

Expand All @@ -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<ushort, string> SnapshotHyperlinks(
IReadOnlyDictionary<ushort, string> live) =>
live.Count == 0
? EmptyHyperlinks
: new Dictionary<ushort, string>(live);

private static readonly IReadOnlyDictionary<ushort, string> EmptyHyperlinks =
new Dictionary<ushort, string>();
Comment on lines +64 to +71
}

public readonly record struct TerminalRenderCell(
Expand All @@ -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,
Expand Down
Loading