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