From 5ad2017ec8fc3d19114803e10dcfab93a3f7a5a9 Mon Sep 17 00:00:00 2001 From: John Campion Jr Date: Sun, 23 Aug 2026 21:11:10 -0400 Subject: [PATCH] Surface every OSC sequence, including the ones this terminal ignores An OSC code with no case in HandleOsc reaches Debug.WriteLine and is gone. The parser already raises EscapeSequenceParser.Osc with the raw payload, but Terminal exposes neither the parser nor the InputHandler, so nothing downstream can compensate -- an embedder wanting OSC 133 shell integration, or OSC 9;9 for a cwd on Windows, has no way to see the bytes arrive. Terminal.OscReceived carries the identifier, the numeric code, the data after the first ';', and the whole payload verbatim. Recognized says whether the sequence reached a handler here, so a listener can implement only what this terminal ignores today and stop doing so by itself once a code lands in HandleOsc, instead of racing it. Note that it is about dispatch, not completeness: OSC 4 is recognized and its handler is still a TODO. Additive by construction. It fires after built-in handling rather than before, so a listener reads terminal state as settled, and nothing it does changes what the terminal already did -- there is deliberately no way to suppress or override handling from here. Tests hold the ordering and the not-disturbing. Splitting on the first ';' only is also contractual rather than incidental: OSC 9;4;1;50 and OSC 133;D; carry their own sub-parameters, and a listener cannot reconstruct them if Data has already been carved up. Co-Authored-By: Claude Opus 5 (1M context) --- src/XTerm.NET.Tests/OscPassthroughTests.cs | 153 +++++++++++++++++++++ src/XTerm.NET/Events/TerminalEvents.cs | 51 +++++++ src/XTerm.NET/InputHandler.cs | 17 +++ src/XTerm.NET/Terminal.cs | 13 ++ 4 files changed, 234 insertions(+) create mode 100644 src/XTerm.NET.Tests/OscPassthroughTests.cs diff --git a/src/XTerm.NET.Tests/OscPassthroughTests.cs b/src/XTerm.NET.Tests/OscPassthroughTests.cs new file mode 100644 index 0000000..ba4ffcf --- /dev/null +++ b/src/XTerm.NET.Tests/OscPassthroughTests.cs @@ -0,0 +1,153 @@ +using XTerm; +using XTerm.Events; +using XTerm.Options; + +namespace XTerm.Tests; + +/// +/// Covers , the escape hatch for OSC codes this terminal does not +/// implement. +/// +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 Capture(Terminal terminal) + { + var seen = new List(); + 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; 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); + } +} diff --git a/src/XTerm.NET/Events/TerminalEvents.cs b/src/XTerm.NET/Events/TerminalEvents.cs index 23d577a..b396995 100644 --- a/src/XTerm.NET/Events/TerminalEvents.cs +++ b/src/XTerm.NET/Events/TerminalEvents.cs @@ -132,6 +132,57 @@ public DirectoryChangeEventArgs(string directory) } } + /// + /// Raw OSC event - fired for EVERY OSC sequence the parser completes, including ones this + /// library does not implement. + /// + /// + /// The escape hatch for OSC codes the terminal does not know yet. Without it an unrecognized + /// sequence reaches Debug.WriteLine and is gone, and nothing downstream can compensate, + /// because the parser's own Osc event is not reachable from . + /// + /// Observation only: this fires AFTER any built-in handling, and setting nothing on it changes + /// what the terminal did. Use to implement only what the library + /// currently ignores, so a handler stops doing so on its own once a code is implemented here. + /// + 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; + } + + /// + /// The identifier field verbatim, before the first ';'. Not always numeric. + /// + public string Identifier { get; } + + /// + /// The identifier as a number, or -1 when it is not numeric. + /// + public int Code { get; } + + /// + /// Everything after the first ';', or empty when the sequence carried no parameters. + /// + public string Data { get; } + + /// + /// The entire payload, identifier included, exactly as the parser delivered it. + /// + public string Raw { get; } + + /// + /// Whether the terminal dispatched this sequence itself. False means it was ignored, and a + /// handler is the only thing that will act on it. + /// + public bool Recognized { get; } + } + /// /// Hyperlink event - fired when a hyperlink is encountered or cleared. /// diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs index 4565e30..b3235f7 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -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)) { @@ -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: @@ -644,6 +649,7 @@ public void HandleOsc(string data) default: // Known but unhandled command + recognized = false; System.Diagnostics.Debug.WriteLine($"Unhandled OSC command: {command}"); break; } @@ -651,8 +657,19 @@ public void HandleOsc(string data) 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) diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index a66ef2d..f4df8fa 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -113,6 +113,15 @@ public class Terminal /// public event EventHandler? HyperlinkChanged; + /// + /// Fired for every OSC sequence, including ones this terminal does not implement. + /// + /// + /// Observation only, raised after any built-in handling. See + /// . + /// + public event EventHandler? OscReceived; + // Window manipulation events /// /// Fired when a window move command is received. @@ -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)); @@ -650,6 +662,7 @@ public void Dispose() LineFed = null; DirectoryChanged = null; HyperlinkChanged = null; + OscReceived = null; // Clear window manipulation events WindowMoved = null;