Skip to content
Merged
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
153 changes: 153 additions & 0 deletions src/XTerm.NET.Tests/OscPassthroughTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using XTerm;
using XTerm.Events;
using XTerm.Options;

namespace XTerm.Tests;

/// <summary>
/// Covers <see cref="Terminal.OscReceived"/>, the escape hatch for OSC codes this terminal does not
/// implement.
/// </summary>
public class OscPassthroughTests
{
private Terminal CreateTerminal(int cols = 80, int rows = 24)
{
var options = new TerminalOptions { Cols = cols, Rows = rows };
return new Terminal(options);
}

private static List<TerminalEvents.OscReceivedEventArgs> Capture(Terminal terminal)
{
var seen = new List<TerminalEvents.OscReceivedEventArgs>();
terminal.OscReceived += (_, e) => seen.Add(e);
return seen;
}

[Fact]
public void OscReceived_FiresForUnknownSequence()
{
// The reason this event exists: OSC 133 is the shell-integration code this terminal has no
// case for, and before this it reached Debug.WriteLine and was unrecoverable.
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]133;A\x07");

var osc = Assert.Single(seen);
Assert.Equal(133, osc.Code);
Assert.Equal("133", osc.Identifier);
Assert.Equal("A", osc.Data);
Assert.Equal("133;A", osc.Raw);
Assert.False(osc.Recognized, "the terminal has no handler for 133, which is the point");
}

[Fact]
public void OscReceived_FiresForKnownSequence_AndReportsItRecognized()
{
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]0;A Title\x07");

var osc = Assert.Single(seen);
Assert.Equal(0, osc.Code);
Assert.Equal("A Title", osc.Data);
Assert.True(osc.Recognized);
}

[Fact]
public void OscReceived_DoesNotDisturbBuiltInHandling()
{
// Purely additive: subscribing must not change what the terminal already did.
var terminal = CreateTerminal();
Capture(terminal);

terminal.Write("\x1B]0;Still Set\x07");

Assert.Equal("Still Set", terminal.Title);
}

[Fact]
public void OscReceived_FiresAfterBuiltInHandling()
{
// Ordering is contractual: a listener reads terminal state as settled, not mid-flight.
var terminal = CreateTerminal();
string? titleWhenObserved = null;
terminal.OscReceived += (_, _) => titleWhenObserved = terminal.Title;

terminal.Write("\x1B]0;Observed\x07");

Assert.Equal("Observed", titleWhenObserved);
}

[Fact]
public void OscReceived_ReportsNegativeCode_ForNonNumericIdentifier()
{
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]notanumber;payload\x07");

var osc = Assert.Single(seen);
Assert.Equal(-1, osc.Code);
Assert.Equal("notanumber", osc.Identifier);
Assert.Equal("payload", osc.Data);
Assert.False(osc.Recognized);
}

[Fact]
public void OscReceived_ReportsEmptyData_WhenSequenceHasNoParameters()
{
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]133\x07");

var osc = Assert.Single(seen);
Assert.Equal(133, osc.Code);
Assert.Equal(string.Empty, osc.Data);
Assert.Equal("133", osc.Raw);
}

[Fact]
public void OscReceived_KeepsDataIntact_WhenPayloadContainsSemicolons()
{
// Only the FIRST ';' separates identifier from data. OSC 9;4 and OSC 133;D;<exit> both carry
// their own sub-parameters, and a handler cannot reconstruct them if this splits too eagerly.
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]9;4;1;50\x07");

var osc = Assert.Single(seen);
Assert.Equal(9, osc.Code);
Assert.Equal("4;1;50", osc.Data);
Assert.Equal("9;4;1;50", osc.Raw);
}

[Fact]
public void OscReceived_FiresOncePerSequence()
{
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]133;A\x07\x1B]133;B\x07\x1B]133;C\x07");

Assert.Equal(3, seen.Count);
Assert.Equal(new[] { "A", "B", "C" }, seen.Select(o => o.Data));
}

[Fact]
public void OscReceived_AcceptsStringTerminator_AsWellAsBel()
{
// Shell-integration snippets in the wild use both terminators; OSC 133 examples ship with ST.
var terminal = CreateTerminal();
var seen = Capture(terminal);

terminal.Write("\x1B]133;D;0\x1B\\");

var osc = Assert.Single(seen);
Assert.Equal(133, osc.Code);
Assert.Equal("D;0", osc.Data);
}
}
51 changes: 51 additions & 0 deletions src/XTerm.NET/Events/TerminalEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,57 @@ public DirectoryChangeEventArgs(string directory)
}
}

/// <summary>
/// Raw OSC event - fired for EVERY OSC sequence the parser completes, including ones this
/// library does not implement.
/// </summary>
/// <remarks>
/// The escape hatch for OSC codes the terminal does not know yet. Without it an unrecognized
/// sequence reaches <c>Debug.WriteLine</c> and is gone, and nothing downstream can compensate,
/// because the parser's own Osc event is not reachable from <see cref="XTerm.Terminal"/>.
///
/// Observation only: this fires AFTER any built-in handling, and setting nothing on it changes
/// what the terminal did. Use <see cref="Recognized"/> to implement only what the library
Comment on lines +144 to +145
/// currently ignores, so a handler stops doing so on its own once a code is implemented here.
/// </remarks>
public class OscReceivedEventArgs : EventArgs
{
public OscReceivedEventArgs(string identifier, int code, string data, string raw, bool recognized)
{
Identifier = identifier;
Code = code;
Data = data;
Raw = raw;
Recognized = recognized;
}

/// <summary>
/// The identifier field verbatim, before the first ';'. Not always numeric.
/// </summary>
public string Identifier { get; }

/// <summary>
/// The identifier as a number, or -1 when it is not numeric.
/// </summary>
public int Code { get; }

/// <summary>
/// Everything after the first ';', or empty when the sequence carried no parameters.
/// </summary>
public string Data { get; }

/// <summary>
/// The entire payload, identifier included, exactly as the parser delivered it.
/// </summary>
public string Raw { get; }

/// <summary>
/// Whether the terminal dispatched this sequence itself. False means it was ignored, and a
/// handler is the only thing that will act on it.
/// </summary>
public bool Recognized { get; }
}

/// <summary>
/// Hyperlink event - fired when a hyperlink is encountered or cleared.
/// </summary>
Expand Down
17 changes: 17 additions & 0 deletions src/XTerm.NET/InputHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,10 @@ public void HandleOsc(string data)

var arg = parts.Length > 1 ? parts[1] : string.Empty;

// Whether this sequence reached a handler. Cleared by the branches that do nothing with it,
// so a listener can tell "the terminal acted on this" from "the terminal saw it and moved on".
var recognized = true;

// Try to parse as OscCommand enum
if (parts[0].TryParseOscCommand(out OscCommand command))
{
Expand All @@ -605,6 +609,7 @@ public void HandleOsc(string data)

case OscCommand.SetIconName:
// Icon name - not typically supported in modern terminals
recognized = false;
break;

case OscCommand.ChangeColor:
Expand Down Expand Up @@ -644,15 +649,27 @@ public void HandleOsc(string data)

default:
// Known but unhandled command
recognized = false;
System.Diagnostics.Debug.WriteLine($"Unhandled OSC command: {command}");
break;
}
}
else
{
// Unknown or unsupported OSC sequence
recognized = false;
System.Diagnostics.Debug.WriteLine($"Unknown OSC sequence: {parts[0]}");
}

// Last, so a listener observes the terminal's own handling as already done rather than
// pending. Raised for recognized sequences too: a listener that only wants the rest can say
// so with Recognized, and stop compensating by itself once a code lands here.
_terminal.RaiseOscReceived(
parts[0],
int.TryParse(parts[0], out var code) ? code : -1,
arg,
data,
recognized);
}

private void HandleColorPaletteChange(string data)
Expand Down
13 changes: 13 additions & 0 deletions src/XTerm.NET/Terminal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ public class Terminal
/// </summary>
public event EventHandler<TerminalEvents.HyperlinkEventArgs>? HyperlinkChanged;

/// <summary>
/// Fired for every OSC sequence, including ones this terminal does not implement.
/// </summary>
/// <remarks>
/// Observation only, raised after any built-in handling. See
/// <see cref="TerminalEvents.OscReceivedEventArgs"/>.
/// </remarks>
public event EventHandler<TerminalEvents.OscReceivedEventArgs>? OscReceived;

// Window manipulation events
/// <summary>
/// Fired when a window move command is received.
Expand Down Expand Up @@ -479,6 +488,9 @@ internal void RaiseDirectoryChanged(string directory) =>

internal void RaiseHyperlinkChanged(string? url) =>
HyperlinkChanged?.Invoke(this, new TerminalEvents.HyperlinkEventArgs(url ?? string.Empty, url == null));

internal void RaiseOscReceived(string identifier, int code, string data, string raw, bool recognized) =>
OscReceived?.Invoke(this, new TerminalEvents.OscReceivedEventArgs(identifier, code, data, raw, recognized));

internal void RaiseWindowMoved(int x, int y) =>
WindowMoved?.Invoke(this, new TerminalEvents.WindowMovedEventArgs(x, y));
Expand Down Expand Up @@ -650,6 +662,7 @@ public void Dispose()
LineFed = null;
DirectoryChanged = null;
HyperlinkChanged = null;
OscReceived = null;

// Clear window manipulation events
WindowMoved = null;
Expand Down
Loading