diff --git a/src/XTerm.NET.Tests/OscPassthroughTests.cs b/src/XTerm.NET.Tests/OscPassthroughTests.cs index ba4ffcf..a179415 100644 --- a/src/XTerm.NET.Tests/OscPassthroughTests.cs +++ b/src/XTerm.NET.Tests/OscPassthroughTests.cs @@ -26,8 +26,32 @@ private Terminal CreateTerminal(int cols = 80, int rows = 24) [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. + // The reason this event exists: a code with no case here reaches Debug.WriteLine and is + // otherwise unrecoverable. OSC 1337 is iTerm2's proprietary space, which this terminal does + // not implement and has no plans to. + // + // This used to use OSC 133, which was unimplemented when the event was added and is not any + // more. That is the Recognized contract working rather than a test going stale: a listener + // filling the gap stops doing so on its own once a code lands in HandleOsc. The pair below + // pins both halves of it. + var terminal = CreateTerminal(); + var seen = Capture(terminal); + + terminal.Write("\x1B]1337;SetMark\x07"); + + var osc = Assert.Single(seen); + Assert.Equal(1337, osc.Code); + Assert.Equal("1337", osc.Identifier); + Assert.Equal("SetMark", osc.Data); + Assert.Equal("1337;SetMark", osc.Raw); + Assert.False(osc.Recognized, "the terminal has no handler for 1337, which is the point"); + } + + [Fact] + public void OscReceived_ReportsShellIntegrationAsRecognized_NowThatItIsImplemented() + { + // The other half: OSC 133 is handled now, so a listener that only wants what this terminal + // ignores must be told to leave it alone. var terminal = CreateTerminal(); var seen = Capture(terminal); @@ -35,10 +59,7 @@ public void OscReceived_FiresForUnknownSequence() 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"); + Assert.True(osc.Recognized, "133 reaches a handler now, and Recognized has to say so"); } [Fact] diff --git a/src/XTerm.NET.Tests/ShellIntegrationTests.cs b/src/XTerm.NET.Tests/ShellIntegrationTests.cs new file mode 100644 index 0000000..c9dc7d7 --- /dev/null +++ b/src/XTerm.NET.Tests/ShellIntegrationTests.cs @@ -0,0 +1,356 @@ +using XTerm; +using XTerm.Common; +using XTerm.Events; +using XTerm.Options; + +namespace XTerm.Tests; + +/// +/// Covers OSC 133 (FinalTerm/FTCS shell integration marks) and OSC 9 (the ConEmu extensions: +/// working directory, progress, notification). +/// +public class ShellIntegrationTests +{ + private Terminal CreateTerminal(int cols = 80, int rows = 24) + { + var options = new TerminalOptions { Cols = cols, Rows = rows }; + return new Terminal(options); + } + + // ---- OSC 133 ----------------------------------------------------------------------------- + + [Fact] + public void ShellIntegrationState_IsNullBeforeAnyMark() + { + // Null is the third state and the reason this property is nullable: a shell with no + // integration configured is indistinguishable from one sitting at a prompt, and defaulting + // to PromptStart would assert the shell is idle on no evidence at all. + var terminal = CreateTerminal(); + + Assert.Null(terminal.ShellIntegrationState); + Assert.Null(terminal.LastCommandExitCode); + } + + [Theory] + [InlineData("A", ShellIntegrationMark.PromptStart)] + [InlineData("B", ShellIntegrationMark.CommandStart)] + [InlineData("C", ShellIntegrationMark.CommandExecuted)] + [InlineData("D", ShellIntegrationMark.CommandFinished)] + public void Osc133_RecordsEachMark(string letter, ShellIntegrationMark expected) + { + var terminal = CreateTerminal(); + TerminalEvents.ShellIntegrationEventArgs? received = null; + terminal.ShellIntegrationMarkReceived += (_, e) => received = e; + + terminal.Write($"\x1B]133;{letter}\x07"); + + Assert.Equal(expected, terminal.ShellIntegrationState); + Assert.NotNull(received); + Assert.Equal(expected, received!.Mark); + } + + [Fact] + public void Osc133_TracksAFullPromptCommandCycle() + { + // The sequence a caller actually depends on: at B the shell is waiting for input, at C + // something else owns the terminal, at D it is the shell's again. + var terminal = CreateTerminal(); + var marks = new List(); + terminal.ShellIntegrationMarkReceived += (_, e) => marks.Add(e.Mark); + + terminal.Write("\x1B]133;A\x07"); + terminal.Write("\x1B]133;B\x07"); + Assert.Equal(ShellIntegrationMark.CommandStart, terminal.ShellIntegrationState); + + terminal.Write("\x1B]133;C\x07"); + Assert.Equal(ShellIntegrationMark.CommandExecuted, terminal.ShellIntegrationState); + + terminal.Write("\x1B]133;D;0\x07"); + Assert.Equal(ShellIntegrationMark.CommandFinished, terminal.ShellIntegrationState); + + Assert.Equal( + new[] + { + ShellIntegrationMark.PromptStart, + ShellIntegrationMark.CommandStart, + ShellIntegrationMark.CommandExecuted, + ShellIntegrationMark.CommandFinished, + }, + marks); + } + + [Fact] + public void Osc133_CapturesExitCode() + { + var terminal = CreateTerminal(); + TerminalEvents.ShellIntegrationEventArgs? received = null; + terminal.ShellIntegrationMarkReceived += (_, e) => received = e; + + terminal.Write("\x1B]133;D;127\x07"); + + Assert.Equal(127, terminal.LastCommandExitCode); + Assert.Equal(127, received!.ExitCode); + } + + [Fact] + public void Osc133_CapturesNegativeExitCode() + { + // Microsoft's own pwsh snippet returns -1 for a PowerShell-native error. + var terminal = CreateTerminal(); + + terminal.Write("\x1B]133;D;-1\x07"); + + Assert.Equal(-1, terminal.LastCommandExitCode); + } + + [Fact] + public void Osc133_LeavesExitCodeNull_WhenTheShellOmitsIt() + { + // cmd.exe cannot read the previous command's status from its prompt, so a bare D is normal + // rather than malformed. Null must not collapse into 0, or every cmd.exe command would look + // like it succeeded. + var terminal = CreateTerminal(); + TerminalEvents.ShellIntegrationEventArgs? received = null; + terminal.ShellIntegrationMarkReceived += (_, e) => received = e; + + terminal.Write("\x1B]133;D\x07"); + + Assert.Equal(ShellIntegrationMark.CommandFinished, terminal.ShellIntegrationState); + Assert.Null(terminal.LastCommandExitCode); + Assert.Null(received!.ExitCode); + } + + [Fact] + public void Osc133_ClearsAStaleExitCode_WhenTheNextCommandReportsNone() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]133;D;3\x07"); + Assert.Equal(3, terminal.LastCommandExitCode); + + terminal.Write("\x1B]133;D\x07"); + Assert.Null(terminal.LastCommandExitCode); + } + + [Fact] + public void Osc133_ReportsNoExitCode_OnMarksThatCannotCarryOne() + { + var terminal = CreateTerminal(); + TerminalEvents.ShellIntegrationEventArgs? received = null; + terminal.ShellIntegrationMarkReceived += (_, e) => received = e; + + terminal.Write("\x1B]133;A\x07"); + + Assert.Null(received!.ExitCode); + } + + [Fact] + public void Osc133_AcceptsStringTerminator() + { + // The bash and cmd snippets in Microsoft's docs terminate with ST, not BEL. + var terminal = CreateTerminal(); + + terminal.Write("\x1B]133;D;0\x1B\\"); + + Assert.Equal(ShellIntegrationMark.CommandFinished, terminal.ShellIntegrationState); + Assert.Equal(0, terminal.LastCommandExitCode); + } + + [Fact] + public void Osc133_IgnoresUnknownMark_WithoutDisturbingState() + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]133;A\x07"); + + terminal.Write("\x1B]133;Z\x07"); + + Assert.Equal(ShellIntegrationMark.PromptStart, terminal.ShellIntegrationState); + } + + [Fact] + public void Osc133_IgnoresEmptyPayload() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]133\x07"); + + Assert.Null(terminal.ShellIntegrationState); + } + + // ---- OSC 9 ; 9 : working directory ------------------------------------------------------- + + [Fact] + public void Osc9Cwd_SetsCurrentDirectory() + { + // Microsoft's documented Windows prompts emit 9;9 rather than OSC 7, so a terminal reading + // only 7 loses the working directory on Windows entirely. + var terminal = CreateTerminal(); + string? reported = null; + terminal.DirectoryChanged += (_, e) => reported = e.Directory; + + terminal.Write("\x1B]9;9;C:\\Users\\me\x07"); + + Assert.Equal("C:\\Users\\me", terminal.CurrentDirectory); + Assert.Equal("C:\\Users\\me", reported); + } + + [Fact] + public void Osc9Cwd_StripsSurroundingQuotes() + { + // The pwsh snippet in Microsoft's docs emits the path already quoted. + var terminal = CreateTerminal(); + + terminal.Write("\x1B]9;9;\"C:\\Program Files\"\x07"); + + Assert.Equal("C:\\Program Files", terminal.CurrentDirectory); + } + + [Fact] + public void Osc9Cwd_IgnoresEmptyPath() + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]9;9;C:\\keep\x07"); + + terminal.Write("\x1B]9;9;\x07"); + + Assert.Equal("C:\\keep", terminal.CurrentDirectory); + } + + // ---- OSC 9 ; 4 : progress ---------------------------------------------------------------- + + [Theory] + [InlineData(0, ProgressState.None)] + [InlineData(1, ProgressState.Normal)] + [InlineData(2, ProgressState.Error)] + [InlineData(3, ProgressState.Indeterminate)] + [InlineData(4, ProgressState.Warning)] + public void Osc9Progress_RecordsEachState(int raw, ProgressState expected) + { + var terminal = CreateTerminal(); + TerminalEvents.ProgressEventArgs? received = null; + terminal.ProgressChanged += (_, e) => received = e; + + terminal.Write($"\x1B]9;4;{raw};50\x07"); + + Assert.Equal(expected, terminal.ProgressState); + Assert.NotNull(received); + Assert.Equal(expected, received!.State); + } + + [Fact] + public void Osc9Progress_RecordsValue() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]9;4;1;42\x07"); + + Assert.Equal(ProgressState.Normal, terminal.ProgressState); + Assert.Equal(42, terminal.ProgressValue); + } + + [Fact] + public void Osc9Progress_ClampsOutOfRangeValue() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]9;4;1;250\x07"); + + Assert.Equal(100, terminal.ProgressValue); + } + + [Fact] + public void Osc9Progress_ZeroesValue_ForStatesThatHaveNone() + { + // Indeterminate carries no percentage; leaving a stale one would render a bar at the old + // position while claiming the extent is unknown. + var terminal = CreateTerminal(); + terminal.Write("\x1B]9;4;1;80\x07"); + + terminal.Write("\x1B]9;4;3\x07"); + + Assert.Equal(ProgressState.Indeterminate, terminal.ProgressState); + Assert.Equal(0, terminal.ProgressValue); + } + + [Fact] + public void Osc9Progress_ClearsOnStateNone() + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]9;4;1;80\x07"); + + terminal.Write("\x1B]9;4;0\x07"); + + Assert.Equal(ProgressState.None, terminal.ProgressState); + Assert.Equal(0, terminal.ProgressValue); + } + + [Fact] + public void Osc9Progress_IgnoresUnknownState() + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]9;4;1;80\x07"); + + terminal.Write("\x1B]9;4;9;10\x07"); + + Assert.Equal(ProgressState.Normal, terminal.ProgressState); + Assert.Equal(80, terminal.ProgressValue); + } + + // ---- OSC 9 : notification ---------------------------------------------------------------- + + [Fact] + public void Osc9Notification_RaisesWithText() + { + var terminal = CreateTerminal(); + string? text = null; + terminal.NotificationReceived += (_, e) => text = e.Text; + + terminal.Write("\x1B]9;Build finished\x07"); + + Assert.Equal("Build finished", text); + } + + [Theory] + [InlineData("9")] + [InlineData("4")] + public void Osc9_IgnoresAClaimedSubCommandWithNoPayload(string subCommand) + { + // "OSC 9;9" carries a sub-command and nothing else. Falling through to the notification case + // would raise a toast whose entire body is "9". + var terminal = CreateTerminal(); + var notifications = new List(); + terminal.NotificationReceived += (_, e) => notifications.Add(e.Text); + + terminal.Write($"\u001b]9;{subCommand}\u0007"); + + Assert.Empty(notifications); + } + + [Fact] + public void Osc9Notification_StillFiresForTextThatContainsSemicolons() + { + // The fallback must keep the whole body. Only a CLAIMED sub-command is special. + var terminal = CreateTerminal(); + string? text = null; + terminal.NotificationReceived += (_, e) => text = e.Text; + + terminal.Write("\u001b]9;Build finished; 3 warnings\u0007"); + + Assert.Equal("Build finished; 3 warnings", text); + } + + [Fact] + public void Osc9Notification_DoesNotFireForProgressOrCwd() + { + // The sub-parameters are not notifications. Without this distinction OSC 9;4 would raise a + // toast reading "4;1;50" on every progress tick. + var terminal = CreateTerminal(); + var notifications = new List(); + terminal.NotificationReceived += (_, e) => notifications.Add(e.Text); + + terminal.Write("\x1B]9;4;1;50\x07"); + terminal.Write("\x1B]9;9;/home/me\x07"); + + Assert.Empty(notifications); + } +} diff --git a/src/XTerm.NET/Common/OscCommand.cs b/src/XTerm.NET/Common/OscCommand.cs index bc6095c..cce33de 100644 --- a/src/XTerm.NET/Common/OscCommand.cs +++ b/src/XTerm.NET/Common/OscCommand.cs @@ -45,6 +45,15 @@ public enum OscCommand /// Hyperlink = 8, + /// + /// ConEmu-style extensions (OSC 9). Multiplexed: the FIRST parameter selects the operation + /// rather than the code doing so, which is why this one name covers three unrelated features. + /// Format: OSC 9 ; 9 ; path ST - current working directory + /// OSC 9 ; 4 ; state ; pct ST - progress reporting + /// OSC 9 ; text ST - desktop notification + /// + ConEmu = 9, + /// /// Set foreground color (OSC 10). /// @@ -66,6 +75,15 @@ public enum OscCommand /// Clipboard = 52, + /// + /// Shell integration marks, FinalTerm/FTCS (OSC 133). + /// Format: OSC 133 ; A ST - start of prompt + /// OSC 133 ; B ST - start of command line, i.e. end of prompt + /// OSC 133 ; C ST - start of command output + /// OSC 133 ; D [; exit] ST - end of command, with optional exit code + /// + ShellIntegration = 133, + /// /// Reset color palette (OSC 104). /// diff --git a/src/XTerm.NET/Common/ProgressState.cs b/src/XTerm.NET/Common/ProgressState.cs new file mode 100644 index 0000000..6c67009 --- /dev/null +++ b/src/XTerm.NET/Common/ProgressState.cs @@ -0,0 +1,33 @@ +namespace XTerm.Common; + +/// +/// Progress state reported by OSC 9 ; 4 (the ConEmu convention, which Windows Terminal renders on +/// the taskbar). +/// +public enum ProgressState +{ + /// + /// No progress indicator; any previous one is cleared. Progress value is not meaningful. + /// + None = 0, + + /// + /// Normal progress, with a value from 0 to 100. + /// + Normal = 1, + + /// + /// An error occurred; the indicator is shown in an error state. + /// + Error = 2, + + /// + /// Work is ongoing but its extent is unknown. Progress value is not meaningful. + /// + Indeterminate = 3, + + /// + /// Progress is paused or in a warning state. + /// + Warning = 4, +} diff --git a/src/XTerm.NET/Common/ShellIntegrationMark.cs b/src/XTerm.NET/Common/ShellIntegrationMark.cs new file mode 100644 index 0000000..6673fde --- /dev/null +++ b/src/XTerm.NET/Common/ShellIntegrationMark.cs @@ -0,0 +1,35 @@ +namespace XTerm.Common; + +/// +/// A shell integration mark, as reported by OSC 133 (FinalTerm/FTCS). +/// +/// +/// These arrive only from a shell that has been configured to emit them, so their ABSENCE says +/// nothing at all: a shell with no integration installed looks identical to one sitting at a prompt. +/// That is why is nullable rather than defaulting +/// to . +/// +public enum ShellIntegrationMark +{ + /// + /// OSC 133 ; A - the start of a prompt. + /// + PromptStart, + + /// + /// OSC 133 ; B - the start of the command line, i.e. the end of the prompt. The shell is + /// waiting for input at this point. + /// + CommandStart, + + /// + /// OSC 133 ; C - the start of command output. Something other than the shell holds the + /// terminal from here until the matching . + /// + CommandExecuted, + + /// + /// OSC 133 ; D - the end of a command, optionally carrying its exit code. + /// + CommandFinished, +} diff --git a/src/XTerm.NET/Events/TerminalEvents.cs b/src/XTerm.NET/Events/TerminalEvents.cs index b396995..8fccaf9 100644 --- a/src/XTerm.NET/Events/TerminalEvents.cs +++ b/src/XTerm.NET/Events/TerminalEvents.cs @@ -132,6 +132,70 @@ public DirectoryChangeEventArgs(string directory) } } + /// + /// Shell integration event - fired for each OSC 133 mark. + /// + public class ShellIntegrationEventArgs : EventArgs + { + public ShellIntegrationEventArgs(ShellIntegrationMark mark, int? exitCode) + { + Mark = mark; + ExitCode = exitCode; + } + + /// + /// Which mark the shell reported. + /// + public ShellIntegrationMark Mark { get; } + + /// + /// The exit code on , when the shell sent + /// one. Null on every other mark, and also on CommandFinished when the shell omitted it -- + /// cmd.exe cannot read the previous command's status from its prompt, so it always omits it. + /// Null means "not reported", never "succeeded". + /// + public int? ExitCode { get; } + } + + /// + /// Progress event - fired for OSC 9 ; 4 progress reports. + /// + public class ProgressEventArgs : EventArgs + { + public ProgressEventArgs(ProgressState state, int value) + { + State = state; + Value = value; + } + + /// + /// The progress state. + /// + public ProgressState State { get; } + + /// + /// Percentage from 0 to 100. Only meaningful for , + /// and . + /// + public int Value { get; } + } + + /// + /// Notification event - fired for OSC 9 desktop notifications. + /// + public class NotificationEventArgs : EventArgs + { + public NotificationEventArgs(string text) + { + Text = text; + } + + /// + /// The notification body as sent by the application. + /// + public string Text { get; } + } + /// /// Raw OSC event - fired for EVERY OSC sequence the parser completes, including ones this /// library does not implement. diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs index 2d53551..a961ad4 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -624,6 +624,14 @@ public void HandleOsc(string data) HandleHyperlink(arg); break; + case OscCommand.ConEmu: + HandleConEmu(arg); + break; + + case OscCommand.ShellIntegration: + HandleShellIntegration(arg); + break; + case OscCommand.ForegroundColor: HandleColorQuery(((int)command).ToString(), arg); break; @@ -719,6 +727,127 @@ private void HandleCurrentDirectory(string data) } } + /// + /// OSC 9 - ConEmu-style extensions, dispatched on the FIRST parameter rather than the code. + /// + private void HandleConEmu(string data) + { + // The sub-parameter decides which feature this is, and the notification form has no + // sub-parameter at all -- OSC 9 ; text -- so it can only be the fallback. That makes the + // ORDER load-bearing rather than incidental: every claimed sub-command has to be matched + // first, or OSC 9;4;1;50 pops a toast reading "4;1;50" on every progress tick. + // + // An unclaimed sub-parameter is therefore a notification by definition, which is the right + // reading of a permissive extension space, and means a future ConEmu code shows up as text + // rather than being dropped. + var parts = data.Split(new[] { ';' }, 2); + + if (parts.Length == 1 && (data == "9" || data == "4")) + { + // A claimed sub-command with nothing after it. Malformed rather than a notification: + // reporting it as one would raise a toast whose entire body is "9". + return; + } + + if (parts.Length == 2 && parts[0] == "9") + { + // OSC 9 ; 9 ; path ST - working directory, the ConEmu convention. Microsoft's documented + // Windows prompts emit THIS rather than OSC 7, so a terminal that only reads 7 silently + // loses the cwd on Windows. Path is bare, not a file:// URI, and pwsh quotes it. + var path = parts[1].Trim('"'); + if (!string.IsNullOrEmpty(path)) + { + _terminal.CurrentDirectory = path; + _terminal.RaiseDirectoryChanged(path); + } + + return; + } + + if (parts.Length == 2 && parts[0] == "4") + { + HandleProgress(parts[1]); + return; + } + + // OSC 9 ; text ST - desktop notification (the iTerm2 reading of this code). + if (!string.IsNullOrEmpty(data)) + { + _terminal.RaiseNotificationReceived(data); + } + } + + /// + /// OSC 9 ; 4 ; state ; progress ST - progress reporting. + /// + private void HandleProgress(string data) + { + var parts = data.Split(';'); + + if (!int.TryParse(parts[0], out var rawState) || !Enum.IsDefined(typeof(ProgressState), rawState)) + { + return; + } + + var state = (ProgressState)rawState; + + // Value is absent for None and Indeterminate, and meaningless anyway; clamped rather than + // rejected, because a sender that overshoots still means "as far as it goes". + var value = 0; + if (parts.Length > 1 && int.TryParse(parts[1], out var parsed)) + { + value = Math.Clamp(parsed, 0, 100); + } + + if (state == ProgressState.None || state == ProgressState.Indeterminate) + { + value = 0; + } + + _terminal.ProgressState = state; + _terminal.ProgressValue = value; + _terminal.RaiseProgressChanged(state, value); + } + + /// + /// OSC 133 - FinalTerm/FTCS shell integration marks. + /// + private void HandleShellIntegration(string data) + { + var parts = data.Split(';'); + if (parts.Length == 0 || parts[0].Length == 0) + { + return; + } + + ShellIntegrationMark mark; + switch (parts[0]) + { + case "A": mark = ShellIntegrationMark.PromptStart; break; + case "B": mark = ShellIntegrationMark.CommandStart; break; + case "C": mark = ShellIntegrationMark.CommandExecuted; break; + case "D": mark = ShellIntegrationMark.CommandFinished; break; + default: return; + } + + int? exitCode = null; + if (mark == ShellIntegrationMark.CommandFinished) + { + // Only D carries one, and it is optional even there: cmd.exe cannot read the previous + // command's status from its prompt and always sends a bare D. Left null rather than + // defaulted to 0, so "not reported" never reads as "succeeded". + if (parts.Length > 1 && int.TryParse(parts[1], out var parsedExit)) + { + exitCode = parsedExit; + } + + _terminal.LastCommandExitCode = exitCode; + } + + _terminal.ShellIntegrationState = mark; + _terminal.RaiseShellIntegrationMark(mark, exitCode); + } + private void HandleHyperlink(string data) { // OSC 8 ; params ; URI ST diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index fbca959..48a58ee 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -67,6 +67,32 @@ public class Terminal public string? CurrentHyperlink { get; set; } /// + /// The most recent OSC 133 shell integration mark, or null if the shell has never sent one. + /// + /// + /// Null is a third state, not a default: shell integration must be configured in the shell, so + /// a shell without it is indistinguishable from one sitting at a prompt. Treat null as "cannot + /// say" rather than folding it into either answer. + /// + /// means the shell is waiting for input; + /// means something else holds the terminal. + /// + public ShellIntegrationMark? ShellIntegrationState { get; internal set; } + + /// + /// The exit code from the last OSC 133 ; D, or null if none has been reported. + /// + public int? LastCommandExitCode { get; internal set; } + + /// + /// The progress state last reported via OSC 9 ; 4. + /// + public ProgressState ProgressState { get; internal set; } = ProgressState.None; + + /// + /// The progress percentage last reported via OSC 9 ; 4, from 0 to 100. + /// + public int ProgressValue { get; internal set; } /// The terminal's colours: the 256-entry palette plus foreground, background and cursor. /// /// @@ -128,6 +154,19 @@ public class Terminal public event EventHandler? HyperlinkChanged; /// + /// Fired for each OSC 133 shell integration mark. + /// + public event EventHandler? ShellIntegrationMarkReceived; + + /// + /// Fired when progress is reported via OSC 9 ; 4. + /// + public event EventHandler? ProgressChanged; + + /// + /// Fired when a desktop notification is requested via OSC 9. + /// + public event EventHandler? NotificationReceived; /// Fired for every OSC sequence, including ones this terminal does not implement. /// /// @@ -504,6 +543,14 @@ internal void RaiseDirectoryChanged(string directory) => internal void RaiseHyperlinkChanged(string? url) => HyperlinkChanged?.Invoke(this, new TerminalEvents.HyperlinkEventArgs(url ?? string.Empty, url == null)); + internal void RaiseShellIntegrationMark(ShellIntegrationMark mark, int? exitCode) => + ShellIntegrationMarkReceived?.Invoke(this, new TerminalEvents.ShellIntegrationEventArgs(mark, exitCode)); + + internal void RaiseProgressChanged(ProgressState state, int value) => + ProgressChanged?.Invoke(this, new TerminalEvents.ProgressEventArgs(state, value)); + + internal void RaiseNotificationReceived(string text) => + NotificationReceived?.Invoke(this, new TerminalEvents.NotificationEventArgs(text)); internal void RaiseOscReceived(string identifier, int code, string data, string raw, bool recognized) => OscReceived?.Invoke(this, new TerminalEvents.OscReceivedEventArgs(identifier, code, data, raw, recognized)); @@ -677,6 +724,9 @@ public void Dispose() LineFed = null; DirectoryChanged = null; HyperlinkChanged = null; + ShellIntegrationMarkReceived = null; + ProgressChanged = null; + NotificationReceived = null; OscReceived = null; // Clear window manipulation events