From da1ec92399bb28e175422c9df48bbe9ac822741c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 12:23:56 -0400 Subject: [PATCH 01/32] refactor: extract shared terminal environment classifier ConsoleReplInteractionPresenter carried private env-var sniffing (WT_SESSION, ConEmuANSI, TERM_PROGRAM, TMUX, TERM prefixes) that the upcoming shell-integration marks would have had to duplicate. Move the logic verbatim to an internal TerminalEnvironmentClassifier shared by terminal-specific emitters. - No behavior change: existing advanced-progress tests pass unmodified. - Promote EnvironmentVariableScope to a shared test helper. - Add focused classifier tests (tmux, screen, WT, ConEmu, tmux-wrapped). Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .../ConsoleReplInteractionPresenter.cs | 26 +------ .../Terminal/TerminalEnvironmentClassifier.cs | 33 +++++++++ .../Given_TerminalEnvironmentClassifier.cs | 72 +++++++++++++++++++ .../Terminal/EnvironmentVariableScope.cs | 31 ++++++++ 4 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs create mode 100644 src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs create mode 100644 src/Repl.Tests/Terminal/EnvironmentVariableScope.cs diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index 590d8ff..3c33870 100644 --- a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs @@ -168,7 +168,7 @@ private bool ShouldEmitAdvancedProgress() { AdvancedProgressMode.Always => true, AdvancedProgressMode.Never => false, - _ => SessionAdvertisesAdvancedProgress() || IsKnownAdvancedProgressTerminal(), + _ => SessionAdvertisesAdvancedProgress() || TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal(), }; } @@ -176,34 +176,10 @@ private static bool IsInteractiveTerminalSession() => (!Console.IsOutputRedirected || ReplSessionIO.IsSessionActive) && !ReplSessionIO.IsProtocolPassthrough; - private static bool IsKnownAdvancedProgressTerminal() - { - if (IsTerminalMultiplexerSession()) - { - return false; - } - - return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WT_SESSION")) - || string.Equals(Environment.GetEnvironmentVariable("ConEmuANSI"), "ON", StringComparison.OrdinalIgnoreCase) - || string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "WezTerm", StringComparison.OrdinalIgnoreCase); - } - private static bool SessionAdvertisesAdvancedProgress() => ReplSessionIO.IsSessionActive && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting); - private static bool IsTerminalMultiplexerSession() - { - if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TMUX"))) - { - return true; - } - - var term = Environment.GetEnvironmentVariable("TERM"); - return term?.StartsWith("screen", StringComparison.OrdinalIgnoreCase) is true - || term?.StartsWith("tmux", StringComparison.OrdinalIgnoreCase) is true; - } - private static string? BuildAdvancedProgressSequence(ReplProgressEvent progress) { var stateCode = progress.State switch diff --git a/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs b/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs new file mode 100644 index 0000000..2e79afd --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs @@ -0,0 +1,33 @@ +namespace Repl; + +/// +/// Classifies the local terminal from environment variables. Shared by features that +/// emit terminal-specific escape sequences (advanced progress, shell-integration marks) +/// so detection heuristics stay in one place. +/// +internal static class TerminalEnvironmentClassifier +{ + public static bool IsTerminalMultiplexerSession() + { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TMUX"))) + { + return true; + } + + var term = Environment.GetEnvironmentVariable("TERM"); + return term?.StartsWith("screen", StringComparison.OrdinalIgnoreCase) is true + || term?.StartsWith("tmux", StringComparison.OrdinalIgnoreCase) is true; + } + + public static bool IsKnownAdvancedProgressTerminal() + { + if (IsTerminalMultiplexerSession()) + { + return false; + } + + return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WT_SESSION")) + || string.Equals(Environment.GetEnvironmentVariable("ConEmuANSI"), "ON", StringComparison.OrdinalIgnoreCase) + || string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "WezTerm", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs b/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs new file mode 100644 index 0000000..d8986c7 --- /dev/null +++ b/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs @@ -0,0 +1,72 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_TerminalEnvironmentClassifier +{ + [TestMethod] + [Description("Multiplexer detection recognizes tmux via the TMUX variable so terminal-specific sequences stay off by default under multiplexers.")] + public void When_TmuxEnvironmentIsActive_Then_MultiplexerSessionIsDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", "/tmp/tmux-1000/default,42,0"), + ("TERM", null)); + + TerminalEnvironmentClassifier.IsTerminalMultiplexerSession().Should().BeTrue(); + } + + [TestMethod] + [Description("Multiplexer detection recognizes GNU screen via the TERM prefix even without a TMUX variable.")] + public void When_TermReportsScreen_Then_MultiplexerSessionIsDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", "screen-256color")); + + TerminalEnvironmentClassifier.IsTerminalMultiplexerSession().Should().BeTrue(); + } + + [TestMethod] + [Description("Windows Terminal (WT_SESSION) is classified as an advanced-progress terminal, preserving the pre-refactor behavior of the progress presenter.")] + public void When_WindowsTerminalSessionIsActive_Then_AdvancedProgressTerminalIsDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + + TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal().Should().BeTrue(); + } + + [TestMethod] + [Description("ConEmu (ConEmuANSI=ON) is classified as an advanced-progress terminal, preserving the pre-refactor behavior of the progress presenter.")] + public void When_ConEmuAnsiIsOn_Then_AdvancedProgressTerminalIsDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", "ON"), + ("TERM_PROGRAM", null)); + + TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal().Should().BeTrue(); + } + + [TestMethod] + [Description("A multiplexer session suppresses advanced-progress classification even when the outer terminal is a known one.")] + public void When_TmuxWrapsWindowsTerminal_Then_AdvancedProgressTerminalIsNotDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", "/tmp/tmux-1000/default,42,0"), + ("TERM", "tmux-256color"), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + + TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal().Should().BeFalse(); + } +} diff --git a/src/Repl.Tests/Terminal/EnvironmentVariableScope.cs b/src/Repl.Tests/Terminal/EnvironmentVariableScope.cs new file mode 100644 index 0000000..a40c12e --- /dev/null +++ b/src/Repl.Tests/Terminal/EnvironmentVariableScope.cs @@ -0,0 +1,31 @@ +namespace Repl.Tests.TerminalSupport; + +/// +/// Saves, overrides, and restores process environment variables around a test. +/// Test classes using this scope must be marked [DoNotParallelize] because +/// environment variables are process-global. +/// +internal sealed class EnvironmentVariableScope : IDisposable +{ + private readonly (string Name, string? PreviousValue)[] _previousValues; + + public EnvironmentVariableScope(params (string Name, string? Value)[] variables) + { + _previousValues = variables + .Select(static variable => (variable.Name, Environment.GetEnvironmentVariable(variable.Name))) + .ToArray(); + + foreach (var (name, value) in variables) + { + Environment.SetEnvironmentVariable(name, value); + } + } + + public void Dispose() + { + foreach (var (name, previousValue) in _previousValues) + { + Environment.SetEnvironmentVariable(name, previousValue); + } + } +} From 66eadb184d6674d59f944479dae44a4db535b9dd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 12:27:52 -0400 Subject: [PATCH 02/32] feat: add ShellIntegrationMarks terminal capability - New TerminalCapabilities.ShellIntegrationMarks flag (1 << 5). - TerminalCapabilitiesClassifier recognizes vscode identities and infers the marks flag for wezterm, iterm, ghostty, windows terminal, and vscode. ConEmu keeps ProgressReporting but never gets marks (it has no OSC 133 support). - TerminalEnvironmentClassifier gains IsKnownShellIntegrationTerminal (WT_SESSION, TERM_PROGRAM=vscode/WezTerm, multiplexers excluded) and IsVsCodeTerminal. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .../Terminal/TerminalCapabilities.cs | 3 ++ .../TerminalCapabilitiesClassifier.cs | 12 ++++++ .../Terminal/TerminalEnvironmentClassifier.cs | 17 ++++++++ .../Given_TerminalCapabilitiesClassifier.cs | 40 +++++++++++++++++ .../Given_TerminalEnvironmentClassifier.cs | 43 +++++++++++++++++++ 5 files changed, 115 insertions(+) diff --git a/src/Repl.Core/Terminal/TerminalCapabilities.cs b/src/Repl.Core/Terminal/TerminalCapabilities.cs index 936f46f..129d88d 100644 --- a/src/Repl.Core/Terminal/TerminalCapabilities.cs +++ b/src/Repl.Core/Terminal/TerminalCapabilities.cs @@ -23,4 +23,7 @@ public enum TerminalCapabilities /// Terminal supports advanced progress reporting sequences. ProgressReporting = 1 << 4, + + /// Terminal renders shell-integration lifecycle marks (OSC 133 / OSC 633). + ShellIntegrationMarks = 1 << 5, } diff --git a/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs b/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs index 973528d..45d86d3 100644 --- a/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs +++ b/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs @@ -25,6 +25,7 @@ public static TerminalCapabilities InferFromIdentity(string? terminalIdentity) || normalized.Contains("ghostty", StringComparison.Ordinal) || normalized.Contains("conemu", StringComparison.Ordinal) || normalized.Contains("windows terminal", StringComparison.Ordinal) + || normalized.Contains("vscode", StringComparison.Ordinal) || normalized.Contains("alacritty", StringComparison.Ordinal) || normalized.Contains("rxvt", StringComparison.Ordinal) || normalized.Contains("konsole", StringComparison.Ordinal) @@ -44,6 +45,17 @@ public static TerminalCapabilities InferFromIdentity(string? terminalIdentity) capabilities |= TerminalCapabilities.ProgressReporting; } + // ConEmu handles OSC 9;4 progress but not FinalTerm/VS Code prompt marks, + // so it deliberately stays out of this list. + if (normalized.Contains("wezterm", StringComparison.Ordinal) + || normalized.Contains("iterm", StringComparison.Ordinal) + || normalized.Contains("ghostty", StringComparison.Ordinal) + || normalized.Contains("windows terminal", StringComparison.Ordinal) + || normalized.Contains("vscode", StringComparison.Ordinal)) + { + capabilities |= TerminalCapabilities.ShellIntegrationMarks; + } + return capabilities; } diff --git a/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs b/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs index 2e79afd..6cdcb8a 100644 --- a/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs +++ b/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs @@ -30,4 +30,21 @@ public static bool IsKnownAdvancedProgressTerminal() || string.Equals(Environment.GetEnvironmentVariable("ConEmuANSI"), "ON", StringComparison.OrdinalIgnoreCase) || string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "WezTerm", StringComparison.OrdinalIgnoreCase); } + + public static bool IsKnownShellIntegrationTerminal() + { + if (IsTerminalMultiplexerSession()) + { + return false; + } + + // ConEmu is deliberately absent: it renders OSC 9;4 progress but not + // FinalTerm/VS Code prompt marks. + return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WT_SESSION")) + || IsVsCodeTerminal() + || string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "WezTerm", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsVsCodeTerminal() => + string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "vscode", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs b/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs index 51c212b..516617c 100644 --- a/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs +++ b/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs @@ -14,4 +14,44 @@ public void When_IdentitySupportsProgress_Then_ProgressCapabilityIsInferred() TerminalCapabilitiesClassifier.InferFromIdentity("iTerm2") .Should().HaveFlag(TerminalCapabilities.ProgressReporting); } + + [TestMethod] + [Description("Terminals known to render FinalTerm/VS Code semantic prompt marks are tagged with ShellIntegrationMarks.")] + public void When_IdentitySupportsShellIntegration_Then_ShellIntegrationMarksCapabilityIsInferred() + { + TerminalCapabilitiesClassifier.InferFromIdentity("Windows Terminal") + .Should().HaveFlag(TerminalCapabilities.ShellIntegrationMarks); + TerminalCapabilitiesClassifier.InferFromIdentity("wezterm") + .Should().HaveFlag(TerminalCapabilities.ShellIntegrationMarks); + TerminalCapabilitiesClassifier.InferFromIdentity("iTerm2") + .Should().HaveFlag(TerminalCapabilities.ShellIntegrationMarks); + TerminalCapabilitiesClassifier.InferFromIdentity("ghostty") + .Should().HaveFlag(TerminalCapabilities.ShellIntegrationMarks); + TerminalCapabilitiesClassifier.InferFromIdentity("vscode") + .Should().HaveFlag(TerminalCapabilities.ShellIntegrationMarks); + } + + [TestMethod] + [Description("A VS Code identity is recognized as a rich terminal (ANSI/VT input), not just an identity report, so hosted VS Code sessions get full capabilities.")] + public void When_IdentityIsVsCode_Then_AnsiCapabilityIsInferred() + { + TerminalCapabilitiesClassifier.InferFromIdentity("vscode") + .Should().HaveFlag(TerminalCapabilities.Ansi); + } + + [TestMethod] + [Description("ConEmu supports OSC 9;4 progress but not OSC 133 marks, so it must not be tagged with ShellIntegrationMarks.")] + public void When_IdentityIsConEmu_Then_ShellIntegrationMarksCapabilityIsNotInferred() + { + TerminalCapabilitiesClassifier.InferFromIdentity("ConEmu") + .Should().NotHaveFlag(TerminalCapabilities.ShellIntegrationMarks); + } + + [TestMethod] + [Description("A dumb terminal identity never advertises shell-integration marks.")] + public void When_IdentityIsDumb_Then_ShellIntegrationMarksCapabilityIsNotInferred() + { + TerminalCapabilitiesClassifier.InferFromIdentity("dumb") + .Should().NotHaveFlag(TerminalCapabilities.ShellIntegrationMarks); + } } diff --git a/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs b/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs index d8986c7..f4fc69c 100644 --- a/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs +++ b/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs @@ -69,4 +69,47 @@ public void When_TmuxWrapsWindowsTerminal_Then_AdvancedProgressTerminalIsNotDete TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal().Should().BeFalse(); } + + [TestMethod] + [Description("TERM_PROGRAM=vscode identifies the VS Code integrated terminal and classifies it as shell-integration capable.")] + public void When_VsCodeTermProgramIsSet_Then_ShellIntegrationTerminalIsDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", "vscode")); + + TerminalEnvironmentClassifier.IsVsCodeTerminal().Should().BeTrue(); + TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal().Should().BeTrue(); + } + + [TestMethod] + [Description("Multiplexers suppress shell-integration classification in Auto mode: marks positioning is unreliable through tmux panes.")] + public void When_TmuxIsActive_Then_ShellIntegrationTerminalIsNotDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", "/tmp/tmux-1000/default,42,0"), + ("TERM", "tmux-256color"), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", "vscode")); + + TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal().Should().BeFalse(); + } + + [TestMethod] + [Description("ConEmu supports advanced progress but not OSC 133 marks, so ConEmuANSI alone must not classify the terminal as shell-integration capable.")] + public void When_ConEmuOnly_Then_ShellIntegrationTerminalIsNotDetected() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", "ON"), + ("TERM_PROGRAM", null)); + + TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal().Should().BeFalse(); + } } From 7bec359612970a211c3e67695f0924e3a98c8446 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 12:38:03 -0400 Subject: [PATCH 03/32] feat: add shell-integration mark emitter and UseTerminalIntegration - ShellIntegrationMarkEmitter: OSC 133 lifecycle marks (A/B/C/D) with an OSC 633 backend under VS Code, including the 633;E command-line report with spec-compliant escaping (backslash, semicolon, control chars). A phase state machine guarantees one D per prompt cycle; gating mirrors advanced progress (passthrough, ANSI, redirection, Auto/Always/Never). - Public surface: ShellIntegrationMode, TerminalIntegrationOptions, and UseTerminalIntegration extensions on CoreReplApp and ReplApp. - ReplOptions carries the integration options internally; null keeps the feature disabled (opt-in). Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/ReplOptions.cs | 6 + .../ReplTerminalIntegrationExtensions.cs | 28 ++ .../Terminal/ShellIntegrationMarkEmitter.cs | 200 ++++++++++ .../Terminal/ShellIntegrationMode.cs | 17 + .../Terminal/TerminalIntegrationOptions.cs | 14 + .../ReplAppTerminalIntegrationExtensions.cs | 27 ++ .../Given_ShellIntegrationMarkEmitter.cs | 352 ++++++++++++++++++ 7 files changed, 644 insertions(+) create mode 100644 src/Repl.Core/Terminal/ReplTerminalIntegrationExtensions.cs create mode 100644 src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs create mode 100644 src/Repl.Core/Terminal/ShellIntegrationMode.cs create mode 100644 src/Repl.Core/Terminal/TerminalIntegrationOptions.cs create mode 100644 src/Repl.Defaults/ReplAppTerminalIntegrationExtensions.cs create mode 100644 src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs diff --git a/src/Repl.Core/ReplOptions.cs b/src/Repl.Core/ReplOptions.cs index 9212621..ac32c0b 100644 --- a/src/Repl.Core/ReplOptions.cs +++ b/src/Repl.Core/ReplOptions.cs @@ -59,4 +59,10 @@ public ReplOptions() /// Gets shell completion setup options. /// public ShellCompletionOptions ShellCompletion { get; } + + /// + /// Gets or sets terminal-integration options. Null (the default) keeps every + /// terminal-integration emitter disabled; set through UseTerminalIntegration. + /// + internal TerminalIntegrationOptions? TerminalIntegration { get; set; } } diff --git a/src/Repl.Core/Terminal/ReplTerminalIntegrationExtensions.cs b/src/Repl.Core/Terminal/ReplTerminalIntegrationExtensions.cs new file mode 100644 index 0000000..33adfe8 --- /dev/null +++ b/src/Repl.Core/Terminal/ReplTerminalIntegrationExtensions.cs @@ -0,0 +1,28 @@ +namespace Repl; + +/// +/// Terminal-integration extensions for the core REPL app. +/// +public static class ReplTerminalIntegrationExtensions +{ + /// + /// Enables the terminal-integration layer. In interactive mode the prompt and command + /// lifecycle are marked with shell-integration sequences (OSC 133, or OSC 633 under + /// VS Code), unlocking command navigation and decorations in capable terminals. + /// Emission is capability-gated; raw escape sequences are never exposed to handlers. + /// + /// Target app. + /// Optional integration settings. + /// The same app instance. + public static CoreReplApp UseTerminalIntegration( + this CoreReplApp app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + + var options = new TerminalIntegrationOptions(); + configure?.Invoke(options); + app.OptionsSnapshot.TerminalIntegration = options; + return app; + } +} diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs new file mode 100644 index 0000000..57f08e8 --- /dev/null +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -0,0 +1,200 @@ +using System.Buffers; +using System.Globalization; +using System.Text; + +namespace Repl; + +/// +/// Emits shell-integration lifecycle marks (OSC 133 generic backend, OSC 633 for +/// VS Code) around the interactive prompt and command execution. Enablement and +/// backend are resolved once per interactive session; a small phase state machine +/// guarantees at most one command-end mark per prompt cycle. +/// +internal sealed class ShellIntegrationMarkEmitter +{ + private enum Phase + { + Idle, + Prompt, + Input, + Executing, + } + + private const string Bell = "\x07"; + + private static readonly SearchValues EscapedCommandLineChars = CreateEscapedCommandLineChars(); + + private readonly bool _enabled; + private readonly bool _isVsCodeBackend; + private readonly string _oscCode; + private Phase _phase; + + private ShellIntegrationMarkEmitter(bool enabled, bool isVsCodeBackend) + { + _enabled = enabled; + _isVsCodeBackend = isVsCodeBackend; + _oscCode = isVsCodeBackend ? "633" : "133"; + } + + public static ShellIntegrationMarkEmitter Create( + TerminalIntegrationOptions? options, + OutputOptions outputOptions) + { + ArgumentNullException.ThrowIfNull(outputOptions); + var enabled = ResolveEnabled(options, outputOptions); + return new ShellIntegrationMarkEmitter(enabled, enabled && IsVsCodeBackend()); + } + + /// Prompt start (mark A): call before writing the prompt text. + public async ValueTask WritePromptStartAsync() + { + if (!_enabled) + { + return; + } + + _phase = Phase.Prompt; + await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};A{Bell}").ConfigureAwait(false); + } + + /// Prompt end / input start (mark B): call after the prompt text, before reading the line. + public async ValueTask WriteInputStartAsync() + { + if (!_enabled || _phase != Phase.Prompt) + { + return; + } + + _phase = Phase.Input; + await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};B{Bell}").ConfigureAwait(false); + } + + /// + /// Command-line report (mark E, VS Code backend only): call after the user commits a + /// non-empty line, before . Silent no-op on OSC 133. + /// + public async ValueTask WriteCommandLineAsync(string commandLine) + { + ArgumentNullException.ThrowIfNull(commandLine); + if (!_enabled || !_isVsCodeBackend || _phase != Phase.Input) + { + return; + } + + await ReplSessionIO.Output.WriteAsync($"\x1b]633;E;{EscapeCommandLine(commandLine)}{Bell}").ConfigureAwait(false); + } + + /// Pre-execution / output start (mark C): call right before dispatching the command. + public async ValueTask WriteOutputStartAsync() + { + if (!_enabled || _phase != Phase.Input) + { + return; + } + + _phase = Phase.Executing; + await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};C{Bell}").ConfigureAwait(false); + } + + /// + /// Command end (mark D): call once per prompt cycle. Pass null for aborted or + /// empty input (FinalTerm "command aborted" form, no exit-code parameter). No-op when + /// no cycle is open, so a double call can never emit two D marks. + /// + public async ValueTask WriteCommandEndAsync(int? exitCode) + { + if (!_enabled || _phase == Phase.Idle) + { + return; + } + + _phase = Phase.Idle; + var suffix = exitCode is { } code + ? $";{code.ToString(CultureInfo.InvariantCulture)}" + : string.Empty; + await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};D{suffix}{Bell}").ConfigureAwait(false); + } + + /// + /// Escapes a command line for the OSC 633;E payload per the VS Code shell-integration + /// spec: \ becomes \\, ; becomes \x3b, and control + /// characters below 0x20 become \xHH (lowercase hex). + /// + internal static string EscapeCommandLine(string commandLine) + { + var first = commandLine.AsSpan().IndexOfAny(EscapedCommandLineChars); + if (first < 0) + { + return commandLine; + } + + const string hexDigits = "0123456789abcdef"; + var builder = new StringBuilder(commandLine.Length + 8); + builder.Append(commandLine, 0, first); + foreach (var ch in commandLine.AsSpan(first)) + { + if (ch == '\\') + { + builder.Append(@"\\"); + } + else if (ch == ';') + { + builder.Append(@"\x3b"); + } + else if (ch < ' ') + { + builder.Append(@"\x").Append(hexDigits[(ch >> 4) & 0xF]).Append(hexDigits[ch & 0xF]); + } + else + { + builder.Append(ch); + } + } + + return builder.ToString(); + } + + private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOptions outputOptions) + { + // Same structural gates as advanced progress (OSC 9;4): marks must never reach + // protocol streams, non-ANSI writers, or redirected local output. + if (options is null + || ReplSessionIO.IsProtocolPassthrough + || !outputOptions.IsAnsiEnabled() + || (Console.IsOutputRedirected && !ReplSessionIO.IsSessionActive)) + { + return false; + } + + return options.ShellIntegration switch + { + ShellIntegrationMode.Always => true, + ShellIntegrationMode.Never => false, + _ => SessionAdvertisesShellIntegration() || TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal(), + }; + } + + private static bool SessionAdvertisesShellIntegration() => + ReplSessionIO.IsSessionActive + && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ShellIntegrationMarks); + + private static bool IsVsCodeBackend() => + TerminalEnvironmentClassifier.IsVsCodeTerminal() + || (ReplSessionIO.IsSessionActive + && ReplSessionIO.TerminalIdentity?.Contains("vscode", StringComparison.OrdinalIgnoreCase) is true); + + // The escape set is backslash, semicolon, and every control character below 0x20; + // built programmatically to keep raw control bytes out of the source file. + private static SearchValues CreateEscapedCommandLineChars() + { + Span chars = stackalloc char[34]; + chars[0] = '\\'; + chars[1] = ';'; + for (var i = 0; i < 32; i++) + { + chars[i + 2] = (char)i; + } + + return SearchValues.Create(chars); + } +} diff --git a/src/Repl.Core/Terminal/ShellIntegrationMode.cs b/src/Repl.Core/Terminal/ShellIntegrationMode.cs new file mode 100644 index 0000000..e8d609c --- /dev/null +++ b/src/Repl.Core/Terminal/ShellIntegrationMode.cs @@ -0,0 +1,17 @@ +namespace Repl.Terminal; + +/// +/// Controls whether shell-integration lifecycle marks (OSC 133 / OSC 633) are emitted +/// around the interactive prompt and command execution. +/// +public enum ShellIntegrationMode +{ + /// Emit marks when the terminal is known to render them (capability or environment detection). + Auto, + + /// Always emit marks on ANSI-capable interactive output. + Always, + + /// Never emit marks. + Never, +} diff --git a/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs b/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs new file mode 100644 index 0000000..d76233f --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs @@ -0,0 +1,14 @@ +namespace Repl.Terminal; + +/// +/// Options for the terminal-integration layer enabled by UseTerminalIntegration. +/// Additional integration intents (hyperlinks, notifications, theme probing) will be +/// added here as they are implemented. +/// +public sealed class TerminalIntegrationOptions +{ + /// + /// Gets or sets how shell-integration lifecycle marks are emitted in interactive mode. + /// + public ShellIntegrationMode ShellIntegration { get; set; } = ShellIntegrationMode.Auto; +} diff --git a/src/Repl.Defaults/ReplAppTerminalIntegrationExtensions.cs b/src/Repl.Defaults/ReplAppTerminalIntegrationExtensions.cs new file mode 100644 index 0000000..be043a0 --- /dev/null +++ b/src/Repl.Defaults/ReplAppTerminalIntegrationExtensions.cs @@ -0,0 +1,27 @@ +using Repl.Terminal; + +namespace Repl; + +/// +/// Terminal-integration extensions for the DI-enabled REPL app. +/// +public static class ReplAppTerminalIntegrationExtensions +{ + /// + /// Enables the terminal-integration layer. In interactive mode the prompt and command + /// lifecycle are marked with shell-integration sequences (OSC 133, or OSC 633 under + /// VS Code), unlocking command navigation and decorations in capable terminals. + /// Emission is capability-gated; raw escape sequences are never exposed to handlers. + /// + /// Target app. + /// Optional integration settings. + /// The same app instance. + public static ReplApp UseTerminalIntegration( + this ReplApp app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + app.Core.UseTerminalIntegration(configure); + return app; + } +} diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs new file mode 100644 index 0000000..d15f05e --- /dev/null +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -0,0 +1,352 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ShellIntegrationMarkEmitter +{ + private static readonly (string Name, string? Value)[] NeutralTerminalEnvironment = + [ + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null), + ]; + + [TestMethod] + [Description("Always mode emits the full OSC 133 lifecycle in order: prompt start (A), input start (B), output start (C), command end with exit code (D).")] + public async Task When_ModeAlways_AndAnsiSession_Then_LifecycleMarksAreEmittedInOrder() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + await emitter.WritePromptStartAsync(); + await emitter.WriteInputStartAsync(); + await emitter.WriteOutputStartAsync(); + await emitter.WriteCommandEndAsync(exitCode: 0); + + var raw = harness.RawOutput; + var promptStart = raw.IndexOf("]133;A", StringComparison.Ordinal); + var inputStart = raw.IndexOf("]133;B", StringComparison.Ordinal); + var outputStart = raw.IndexOf("]133;C", StringComparison.Ordinal); + var commandEnd = raw.IndexOf("]133;D;0", StringComparison.Ordinal); + promptStart.Should().BeGreaterThanOrEqualTo(0); + inputStart.Should().BeGreaterThan(promptStart); + outputStart.Should().BeGreaterThan(inputStart); + commandEnd.Should().BeGreaterThan(outputStart); + } + + [TestMethod] + [Description("Never mode suppresses every mark even on a capable ANSI session.")] + public async Task When_ModeNever_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Never); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("Auto mode emits OSC 133 marks when Windows Terminal is detected through WT_SESSION.")] + public async Task When_ModeAuto_AndWindowsTerminalDetected_Then_Osc133MarksAreEmitted() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().Contain("]133;A"); + harness.RawOutput.Should().Contain("]133;D;0"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("Auto mode selects the OSC 633 backend under VS Code (TERM_PROGRAM=vscode) and reports the command line with 633;E between input start and output start.")] + public async Task When_ModeAuto_AndVsCodeDetected_Then_Osc633MarksAreEmittedIncludingCommandLine() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", "vscode")); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await emitter.WritePromptStartAsync(); + await emitter.WriteInputStartAsync(); + await emitter.WriteCommandLineAsync("ping"); + await emitter.WriteOutputStartAsync(); + await emitter.WriteCommandEndAsync(exitCode: 0); + + var raw = harness.RawOutput; + raw.Should().NotContain("]133;"); + var inputStart = raw.IndexOf("]633;B", StringComparison.Ordinal); + var commandLine = raw.IndexOf("]633;E;ping", StringComparison.Ordinal); + var outputStart = raw.IndexOf("]633;C", StringComparison.Ordinal); + inputStart.Should().BeGreaterThanOrEqualTo(0); + commandLine.Should().BeGreaterThan(inputStart); + outputStart.Should().BeGreaterThan(commandLine); + raw.Should().Contain("]633;D;0"); + } + + [TestMethod] + [Description("Auto mode stays silent under tmux even when the outer terminal is capable: mark positioning is unreliable through multiplexers.")] + public async Task When_ModeAuto_AndTmuxDetected_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope( + ("TMUX", "/tmp/tmux-1000/default,42,0"), + ("TERM", "tmux-256color"), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("Auto mode honors a hosted session that advertises ShellIntegrationMarks through its terminal identity, without any local environment hint.")] + public async Task When_ModeAuto_AndHostedSessionAdvertisesShellIntegrationMarks_Then_MarksAreEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().Contain("]133;A"); + } + + [TestMethod] + [Description("A hosted session reporting a VS Code identity selects the OSC 633 backend even without TERM_PROGRAM in the host process environment.")] + public async Task When_HostedSessionReportsVsCodeIdentity_Then_Osc633BackendIsSelected() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + ReplSessionIO.TerminalIdentity = "vscode"; + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().Contain("]633;A"); + harness.RawOutput.Should().NotContain("]133;"); + } + + [TestMethod] + [Description("Protocol passthrough (raw bytes piped to stdout) suppresses every mark regardless of mode: OSC bytes must never corrupt protocol streams.")] + public async Task When_ProtocolPassthroughIsActive_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + using var passthrough = ReplSessionIO.PushProtocolPassthrough(); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("Disabled ANSI output suppresses every mark regardless of mode: escape bytes must never reach non-ANSI writers.")] + public async Task When_AnsiIsDisabled_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Never); + var emitter = ShellIntegrationMarkEmitter.Create( + new TerminalIntegrationOptions { ShellIntegration = ShellIntegrationMode.Always }, + new OutputOptions { AnsiMode = AnsiMode.Never }); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("A null options instance (UseTerminalIntegration never called) keeps the emitter fully disabled: the feature is opt-in.")] + public async Task When_OptionsAreNull_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = ShellIntegrationMarkEmitter.Create( + options: null, + new OutputOptions { AnsiMode = AnsiMode.Always }); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("The 633;E payload escapes backslashes, semicolons, and control characters per the VS Code shell-integration spec so the reported command line round-trips exactly.")] + public void When_CommandLineContainsBackslashSemicolonAndControlChars_Then_Osc633PayloadIsEscaped() + { + ShellIntegrationMarkEmitter.EscapeCommandLine(@"a\b;c" + "\n") + .Should().Be(@"a\\b\x3bc\x0a"); + ShellIntegrationMarkEmitter.EscapeCommandLine("plain text stays intact") + .Should().Be("plain text stays intact"); + ShellIntegrationMarkEmitter.EscapeCommandLine("tab\there") + .Should().Be(@"tab\x09here"); + } + + [TestMethod] + [Description("An aborted or empty command reports D without an exit-code parameter, matching the FinalTerm 'command aborted' form.")] + public async Task When_CommandEndsWithoutExitCode_Then_DIsEmittedWithoutParameter() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + await emitter.WritePromptStartAsync(); + await emitter.WriteInputStartAsync(); + await emitter.WriteCommandEndAsync(exitCode: null); + + harness.RawOutput.Should().Contain("]133;D"); + harness.RawOutput.Should().NotContain("]133;D;"); + } + + [TestMethod] + [Description("The phase state machine makes a second command-end call a no-op so a command can never report two D marks.")] + public async Task When_CommandEndIsCalledTwice_Then_OnlyOneDIsEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + await RunFullLifecycleAsync(emitter); + await emitter.WriteCommandEndAsync(exitCode: 1); + + CountOccurrences(harness.RawOutput, "]133;D").Should().Be(1); + } + + [TestMethod] + [Description("The generic OSC 133 backend has no command-line mark, so WriteCommandLineAsync is a silent no-op outside VS Code.")] + public async Task When_GenericBackendIsActive_Then_CommandLineMarkIsSuppressed() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + await emitter.WritePromptStartAsync(); + await emitter.WriteInputStartAsync(); + await emitter.WriteCommandLineAsync("ping"); + await emitter.WriteOutputStartAsync(); + await emitter.WriteCommandEndAsync(exitCode: 0); + + harness.RawOutput.Should().NotContain(";E;"); + } + + private static ShellIntegrationMarkEmitter CreateEmitter(ShellIntegrationMode mode) => + ShellIntegrationMarkEmitter.Create( + new TerminalIntegrationOptions { ShellIntegration = mode }, + new OutputOptions { AnsiMode = AnsiMode.Always }); + + private static async Task RunFullLifecycleAsync(ShellIntegrationMarkEmitter emitter) + { + await emitter.WritePromptStartAsync().ConfigureAwait(false); + await emitter.WriteInputStartAsync().ConfigureAwait(false); + await emitter.WriteOutputStartAsync().ConfigureAwait(false); + await emitter.WriteCommandEndAsync(exitCode: 0).ConfigureAwait(false); + } + + private static int CountOccurrences(string text, string needle) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } +} From 0a2ea6f42bf7219d78b1af2f80578eac0d983be9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 13:02:29 -0400 Subject: [PATCH 04/32] feat: emit shell-integration marks around the interactive loop InteractiveSession now drives a ShellIntegrationMarkEmitter through the prompt cycle: A before the prompt text, B before the line read, 633;E (VS Code backend) plus C after a non-empty commit, and a single D per cycle owned by the loop. - Exit codes now flow to the D mark: DispatchInteractiveCommandAsync, ExecuteWithCancellationAsync, and ExecuteInteractiveInputAsync return the exit code instead of discarding it (help renders 0/1, matched commands surface ExecuteMatchedCommandAsync's computed code, context navigation 0/1, route failures 1, ambient errors 1). - Cancelled commands report 130 (128+SIGINT) alongside the existing Cancelled. message. - Aborted cycles (Escape, EOF, empty commit) emit D without an exit code and skip C, matching the FinalTerm aborted-command form. - Ambient commands, ambiguous prefixes, and failures all run inside the C..D region because C is emitted by the loop before dispatch. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 54 ++-- ...nteractiveSession_ShellIntegrationMarks.cs | 274 ++++++++++++++++++ 2 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index d822b13..884c33f 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -39,11 +39,13 @@ internal async ValueTask RunInteractiveSessionAsync( var scopeTokens = initialScopeTokens.ToList(); var historyProvider = serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider; string? lastHistoryEntry = null; + var marks = ShellIntegrationMarkEmitter.Create(app.OptionsSnapshot.TerminalIntegration, app.OptionsSnapshot.Output); await app.ShellCompletionRuntimeInstance.HandleStartupAsync(serviceProvider, cancellationToken).ConfigureAwait(false); while (true) { cancellationToken.ThrowIfCancellationRequested(); var readResult = await ReadInteractiveInputAsync( + marks, scopeTokens, historyProvider, serviceProvider, @@ -51,6 +53,7 @@ internal async ValueTask RunInteractiveSessionAsync( .ConfigureAwait(false); if (readResult.Escaped) { + await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); await ReplSessionIO.Output.WriteLineAsync().ConfigureAwait(false); continue; // Esc at bare prompt → fresh line. } @@ -58,12 +61,14 @@ internal async ValueTask RunInteractiveSessionAsync( var line = readResult.Line; if (line is null) { + await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); return 0; } var inputTokens = TokenizeInteractiveInput(line); if (inputTokens.Count == 0) { + await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); continue; } @@ -74,9 +79,12 @@ internal async ValueTask RunInteractiveSessionAsync( cancellationToken) .ConfigureAwait(false); - var outcome = await DispatchInteractiveCommandAsync( + await marks.WriteCommandLineAsync(line).ConfigureAwait(false); + await marks.WriteOutputStartAsync().ConfigureAwait(false); + var (outcome, exitCode) = await DispatchInteractiveCommandAsync( inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); + await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); if (outcome == AmbientCommandOutcome.Exit) { return 0; @@ -85,13 +93,16 @@ internal async ValueTask RunInteractiveSessionAsync( } private async ValueTask ReadInteractiveInputAsync( + ShellIntegrationMarkEmitter marks, IReadOnlyList scopeTokens, IHistoryProvider? historyProvider, IServiceProvider serviceProvider, CancellationToken cancellationToken) { + await marks.WritePromptStartAsync().ConfigureAwait(false); await ReplSessionIO.Output.WriteAsync(BuildPrompt(scopeTokens)).ConfigureAwait(false); await ReplSessionIO.Output.WriteAsync(' ').ConfigureAwait(false); + await marks.WriteInputStartAsync().ConfigureAwait(false); var effectiveMode = app.Autocomplete.ResolveEffectiveAutocompleteMode(serviceProvider); var renderMode = AutocompleteEngine.ResolveAutocompleteRenderMode(effectiveMode); var colorStyles = app.Autocomplete.ResolveAutocompleteColorStyles(renderMode == ConsoleLineReader.AutocompleteRenderMode.Rich); @@ -129,7 +140,7 @@ internal async ValueTask RunInteractiveSessionAsync( return line; } - private async ValueTask DispatchInteractiveCommandAsync( + private async ValueTask<(AmbientCommandOutcome Outcome, int ExitCode)> DispatchInteractiveCommandAsync( List inputTokens, List scopeTokens, IServiceProvider serviceProvider, @@ -143,11 +154,14 @@ private async ValueTask DispatchInteractiveCommandAsync( isInteractiveSession: true, cancellationToken) .ConfigureAwait(false); - if (ambientOutcome is AmbientCommandOutcome.Exit - or AmbientCommandOutcome.Handled - or AmbientCommandOutcome.HandledError) + if (ambientOutcome is AmbientCommandOutcome.Exit or AmbientCommandOutcome.Handled) { - return ambientOutcome; + return (ambientOutcome, 0); + } + + if (ambientOutcome is AmbientCommandOutcome.HandledError) + { + return (ambientOutcome, 1); } var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); @@ -159,16 +173,16 @@ or AmbientCommandOutcome.Handled var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(prefixResolution); _ = await app.RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken, isInteractive: true) .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; + return (AmbientCommandOutcome.Handled, 1); } var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - await ExecuteWithCancellationAsync(resolvedOptions, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + var exitCode = await ExecuteWithCancellationAsync(resolvedOptions, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; + return (AmbientCommandOutcome.Handled, exitCode); } - private async ValueTask ExecuteWithCancellationAsync( + private async ValueTask ExecuteWithCancellationAsync( GlobalInvocationOptions resolvedOptions, List scopeTokens, IServiceProvider serviceProvider, @@ -181,12 +195,15 @@ private async ValueTask ExecuteWithCancellationAsync( try { - await ExecuteInteractiveInputAsync(resolvedOptions, scopeTokens, serviceProvider, commandCts.Token) + return await ExecuteInteractiveInputAsync(resolvedOptions, scopeTokens, serviceProvider, commandCts.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { await ReplSessionIO.Output.WriteLineAsync("Cancelled.").ConfigureAwait(false); + // 128 + SIGINT(2): the shell convention for an interrupted command, so + // shell-integration marks decorate it as interrupted rather than failed. + return 130; } finally { @@ -203,7 +220,7 @@ private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, C } } - private async ValueTask ExecuteInteractiveInputAsync( + private async ValueTask ExecuteInteractiveInputAsync( GlobalInvocationOptions globalOptions, List scopeTokens, IServiceProvider serviceProvider, @@ -212,16 +229,16 @@ private async ValueTask ExecuteInteractiveInputAsync( var activeGraph = app.ResolveActiveRoutingGraph(); if (globalOptions.HelpRequested) { - _ = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); - return; + var rendered = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); + return rendered ? 0 : 1; } var resolution = app.ResolveWithDiagnostics(globalOptions.RemainingTokens, activeGraph.Routes); var match = resolution.Match; if (match is not null) { - await app.ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); - return; + var (exitCode, _) = await app.ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); + return exitCode; } var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, app.OptionsSnapshot.Parsing); @@ -237,7 +254,7 @@ private async ValueTask ExecuteInteractiveInputAsync( cancellationToken, isInteractive: true) .ConfigureAwait(false); - return; + return 1; } scopeTokens.Clear(); @@ -248,7 +265,7 @@ private async ValueTask ExecuteInteractiveInputAsync( await app.InvokeBannerAsync(contextBanner, serviceProvider, cancellationToken).ConfigureAwait(false); } - return; + return 0; } var failure = app.CreateRouteResolutionFailureResult( @@ -261,6 +278,7 @@ private async ValueTask ExecuteInteractiveInputAsync( cancellationToken, isInteractive: true) .ConfigureAwait(false); + return 1; } [SuppressMessage( diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs new file mode 100644 index 0000000..b1a81cd --- /dev/null +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -0,0 +1,274 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_InteractiveSession_ShellIntegrationMarks +{ + private static readonly (string Name, string? Value)[] NeutralTerminalEnvironment = + [ + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null), + ]; + + [TestMethod] + [Description("A successful interactive command is wrapped by the full lifecycle: prompt start, input start, output start, command output, and command end with exit code 0, then the next prompt starts a new cycle.")] + public void When_CommandSucceeds_Then_PromptInputOutputAndEndMarksWrapTheCommand() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r"); + + var promptStart = raw.IndexOf("]133;A", StringComparison.Ordinal); + var inputStart = raw.IndexOf("]133;B", StringComparison.Ordinal); + var outputStart = raw.IndexOf("]133;C", StringComparison.Ordinal); + var commandOutput = raw.IndexOf("pong", StringComparison.Ordinal); + var commandEnd = raw.IndexOf("]133;D;0", StringComparison.Ordinal); + promptStart.Should().BeGreaterThanOrEqualTo(0); + inputStart.Should().BeGreaterThan(promptStart); + outputStart.Should().BeGreaterThan(inputStart); + commandOutput.Should().BeGreaterThan(outputStart); + commandEnd.Should().BeGreaterThan(commandOutput); + raw.IndexOf("]133;A", commandEnd, StringComparison.Ordinal).Should().BeGreaterThan(commandEnd); + } + + [TestMethod] + [Description("A command returning an error result reports exit code 1 in the command-end mark so terminals decorate the command as failed.")] + public void When_CommandReturnsError_Then_CommandEndReportsExitCodeOne() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("boom", () => Results.Error("boom-failed", "nope")); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "boom\rexit\r"); + + raw.Should().Contain("]133;D;1"); + } + + [TestMethod] + [Description("An unknown command resolves to a route-resolution failure and reports exit code 1 in the command-end mark.")] + public void When_UnknownCommandIsEntered_Then_CommandEndReportsExitCodeOne() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "zorglub\rexit\r"); + + raw.Should().Contain("]133;D;1"); + } + + [TestMethod] + [Description("Ambient commands such as help run inside the same command lifecycle: their output lands between output-start and a successful command-end mark.")] + public void When_HelpAmbientCommandRuns_Then_MarksWrapHelpOutput() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong").WithDescription("Answers with pong."); + var harness = new TerminalHarness(cols: 80, rows: 24); + + var raw = RunInteractiveSession(harness, sut, "help\rexit\r"); + + var outputStart = raw.IndexOf("]133;C", StringComparison.Ordinal); + var helpOutput = raw.IndexOf("Answers with pong.", StringComparison.Ordinal); + var commandEnd = raw.IndexOf("]133;D;0", StringComparison.Ordinal); + outputStart.Should().BeGreaterThanOrEqualTo(0); + helpOutput.Should().BeGreaterThan(outputStart); + commandEnd.Should().BeGreaterThan(helpOutput); + } + + [TestMethod] + [Description("Committing an empty line aborts the cycle: command-end is reported without an exit code and no output-start mark is emitted before it.")] + public void When_EmptyLineIsCommitted_Then_CommandEndsWithoutExitCodeAndWithoutOutputMark() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "\rexit\r"); + + var firstCommandEnd = raw.IndexOf("]133;D", StringComparison.Ordinal); + var firstOutputStart = raw.IndexOf("]133;C", StringComparison.Ordinal); + firstCommandEnd.Should().BeGreaterThanOrEqualTo(0); + raw.Substring(firstCommandEnd, 8).Should().NotContain("D;"); + firstOutputStart.Should().BeGreaterThan(firstCommandEnd, because: "the only output-start mark belongs to the later exit command"); + } + + [TestMethod] + [Description("Escaping at an empty prompt abandons the cycle: command-end is reported without an exit code, matching the FinalTerm aborted-command form.")] + public void When_EscapeAbandonsThePrompt_Then_CommandEndsWithoutExitCode() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "\u001bexit\r"); + + var firstCommandEnd = raw.IndexOf("]133;D", StringComparison.Ordinal); + firstCommandEnd.Should().BeGreaterThanOrEqualTo(0); + raw.Substring(firstCommandEnd, 8).Should().NotContain("D;"); + } + + [TestMethod] + [Description("The exit ambient command closes its own cycle: the final command-end mark reports exit code 0 and no mark follows it.")] + public void When_ExitCommandRuns_Then_FinalCommandEndIsEmittedBeforeSessionEnds() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "exit\r"); + + var commandEnd = raw.IndexOf("]133;D;0", StringComparison.Ordinal); + commandEnd.Should().BeGreaterThanOrEqualTo(0); + raw.IndexOf("]133;", commandEnd + 1, StringComparison.Ordinal).Should().Be(-1); + } + + [TestMethod] + [Description("A handler cancelled mid-command keeps the Cancelled. message and reports exit code 130 (128+SIGINT), the shell convention terminals interpret as an interrupted command.")] + public void When_HandlerThrowsOperationCanceled_Then_CancelledLineIsPrintedAndExitCodeIs130() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("boom", string () => throw new OperationCanceledException()); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "boom\rexit\r"); + + raw.Should().Contain("Cancelled."); + raw.Should().Contain("]133;D;130"); + } + + [TestMethod] + [Description("Under the VS Code integrated terminal the OSC 633 backend reports the committed command line with 633;E so command detection is independent of screen scraping.")] + public void When_VsCodeTerminalIsDetected_Then_CommandLineIsReportedWithOsc633E() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", "vscode")); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r"); + + var inputStart = raw.IndexOf("]633;B", StringComparison.Ordinal); + var commandLine = raw.IndexOf("]633;E;ping", StringComparison.Ordinal); + var outputStart = raw.IndexOf("]633;C", StringComparison.Ordinal); + inputStart.Should().BeGreaterThanOrEqualTo(0); + commandLine.Should().BeGreaterThan(inputStart); + outputStart.Should().BeGreaterThan(commandLine); + raw.Should().NotContain("]133;"); + } + + [TestMethod] + [Description("Without UseTerminalIntegration the interactive loop emits no shell-integration marks at all: the feature is opt-in.")] + public void When_IntegrationIsNotConfigured_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var sut = ReplApp.Create().UseDefaultInteractive(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r"); + + raw.Should().Contain("pong"); + raw.Should().NotContain("]133;"); + raw.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("Interaction events written by a handler (status lines) do not open nested lifecycle cycles: exactly one prompt-start mark per loop iteration.")] + public void When_HandlerWritesInteractionEvents_Then_OnlyLoopPromptsEmitMarks() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("work", async (IReplInteractionChannel channel) => + { + await channel.WriteStatusAsync("working on it", CancellationToken.None).ConfigureAwait(false); + return "done"; + }); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "work\rexit\r"); + + raw.Should().Contain("working on it"); + CountOccurrences(raw, "]133;A").Should().Be(2, because: "only the work and exit prompt cycles may open a lifecycle"); + } + + private static ReplApp CreateMarkedApp(ShellIntegrationMode mode = ShellIntegrationMode.Always) + { + var sut = ReplApp.Create() + .UseDefaultInteractive() + .UseTerminalIntegration(options => options.ShellIntegration = mode); + sut.Options(options => options.Output.AnsiMode = AnsiMode.Always); + return sut; + } + + private static string RunInteractiveSession(TerminalHarness harness, ReplApp sut, string typedInput) + { + var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); + var previousReader = ReplSessionIO.KeyReader; + using var scope = ReplSessionIO.SetSession(harness.Writer, TextReader.Null); + try + { + ReplSessionIO.KeyReader = keyReader; + ReplSessionIO.WindowSize = (harness.Cols, harness.Rows); + ReplSessionIO.AnsiSupport = true; + ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; + + _ = sut.Run([]); + return harness.RawOutput; + } + finally + { + ReplSessionIO.KeyReader = previousReader; + } + } + + private static ConsoleKeyInfo ToKeyInfo(char ch) => + ch switch + { + '\r' => new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false), + '\u001b' => new ConsoleKeyInfo('\u001b', ConsoleKey.Escape, shift: false, alt: false, control: false), + _ => new ConsoleKeyInfo(ch, CharToConsoleKey(ch), shift: false, alt: false, control: false), + }; + + private static ConsoleKey CharToConsoleKey(char ch) => + char.IsAsciiLetter(ch) + ? ConsoleKey.A + (char.ToUpperInvariant(ch) - 'A') + : ConsoleKey.Spacebar; + + private static int CountOccurrences(string text, string needle) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } +} From d489825c8c9ba6cf81f70ac6bd52cb959e8f7f6a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 13:06:07 -0400 Subject: [PATCH 05/32] docs: document terminal shell integration - New docs/terminal-shell-integration.md: marks, modes, gating, backend selection, exit-code conventions, hosted sessions. - Cross-links from configuration-reference (TerminalIntegrationOptions), interactive-loop (lifecycle positions), and terminal-metadata (capability gating). Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- docs/configuration-reference.md | 6 +++ docs/interactive-loop.md | 2 + docs/terminal-metadata.md | 2 + docs/terminal-shell-integration.md | 67 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 docs/terminal-shell-integration.md diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 0919b8f..2ff2fd9 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -156,6 +156,12 @@ These options are configured through `app.Options(...)`. Repl does not currently - `AdvancedProgressMode` (`AdvancedProgressMode`, default: `Auto`) — Controls whether compatible hosts emit advanced terminal progress sequences. See [Progress](progress.md#advanced-terminal-progress). - `PromptFallback` (`PromptFallback`, default: `UseDefault`) — Behavior when interactive prompts are unavailable. +## TerminalIntegrationOptions + +Configured through `app.UseTerminalIntegration(...)` (opt-in; no marks are emitted without the call). See [Terminal Shell Integration](terminal-shell-integration.md). + +- `ShellIntegration` (`ShellIntegrationMode`, default: `Auto`) — Controls whether shell-integration lifecycle marks (OSC 133 / OSC 633) are emitted around the interactive prompt and command execution. + ## ShellCompletionOptions Accessed via `ReplOptions.ShellCompletion`. See [Shell Completion](shell-completion.md) for setup details. diff --git a/docs/interactive-loop.md b/docs/interactive-loop.md index 409c378..34f3675 100644 --- a/docs/interactive-loop.md +++ b/docs/interactive-loop.md @@ -26,6 +26,8 @@ app.Map("setup", () => Results.EnterInteractive()); // explicit transition 5. Execute the command through the pipeline. 6. Repeat until exit. +When [terminal shell integration](terminal-shell-integration.md) is enabled, the loop brackets each cycle with semantic marks: prompt start before step 1, input start before step 2, the command-line report (VS Code) and output start between steps 3 and 5, and a single command-end mark carrying the exit code after step 5. + ## Prompt and Autocompletion The prompt displays the current scope path. As the user types, the autocompletion system suggests available commands, contexts, and option names visible from the current scope. Tab completion resolves candidates from the command graph relative to `scopeTokens`. diff --git a/docs/terminal-metadata.md b/docs/terminal-metadata.md index 0b7a246..cc441f5 100644 --- a/docs/terminal-metadata.md +++ b/docs/terminal-metadata.md @@ -51,6 +51,8 @@ using Repl.Terminal; | `TerminalCapabilities` | yes | `IReplSessionInfo.TerminalCapabilities` | | `LastUpdatedUtc` | yes | diagnostics/debugging freshness | +Capability flags also gate terminal-specific emitters: `ProgressReporting` enables advanced progress sequences (OSC 9;4) and `ShellIntegrationMarks` enables shell-integration lifecycle marks — see [Terminal Shell Integration](terminal-shell-integration.md). + ## Parsing and application points by source | Source | Parsed at | Not parsed at | Fields that can change | diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md new file mode 100644 index 0000000..3862c48 --- /dev/null +++ b/docs/terminal-shell-integration.md @@ -0,0 +1,67 @@ +# Terminal Shell Integration + +Modern terminals understand semantic marks that delimit the prompt, the user input, and the command output. When those marks are present, the terminal can offer command navigation (jump between commands), command-aware selection and copy, success/failure decorations in the gutter, and sticky command headers. + +Repl owns the prompt and the command lifecycle in interactive mode, so it can emit those marks itself — no shell script hooks required. The feature is opt-in: + +```csharp +var app = ReplApp.Create() + .UseTerminalIntegration(); // ShellIntegration = Auto by default + +// or explicitly: +app.UseTerminalIntegration(options => +{ + options.ShellIntegration = ShellIntegrationMode.Always; +}); +``` + +Raw escape sequences are never exposed to command handlers; Repl chooses the protocol and emits the marks around its own prompt loop. + +## What gets emitted + +In interactive REPL mode, each prompt cycle is delimited with the FinalTerm semantic sequence (OSC 133), or the VS Code shell-integration sequence (OSC 633) when the VS Code integrated terminal is detected: + +| Moment | Mark | +|---|---| +| Before the prompt text | `A` (prompt start) | +| After the prompt text, before input | `B` (input start) | +| After a committed line (VS Code only) | `E;` (command-line report) | +| Right before command execution | `C` (output start) | +| After the command completes | `D;` (command end) | + +Exit codes follow shell conventions: `0` for success, `1` for errors (failed results, unknown commands, validation failures), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form. + +The VS Code `E` mark reports the exact committed command line (with protocol escaping), which makes VS Code's command detection independent of what is visible on screen. + +CLI one-shot mode emits no marks: Repl does not own the surrounding shell prompt there, and fake prompt markers would corrupt the host shell's own command navigation. Nested interaction prompts (`IReplInteractionChannel` questions asked *during* a command) emit no marks either — they are not shell prompts. + +## Modes + +`ShellIntegrationMode` mirrors the existing `AdvancedProgressMode` semantics: + +- `Auto` (default) — emit when the terminal is known to render marks: the hosted session advertises `TerminalCapabilities.ShellIntegrationMarks`, or the local environment identifies Windows Terminal (`WT_SESSION`), VS Code (`TERM_PROGRAM=vscode`), or WezTerm (`TERM_PROGRAM=WezTerm`). Multiplexers (tmux, GNU screen) stay off: mark positioning is unreliable through panes. +- `Always` — emit whenever the structural gates allow it (see below). Useful for terminals that render marks but are not auto-detected, such as iTerm2 reached over SSH. +- `Never` — never emit. + +Regardless of mode, marks are never written when: + +- output is redirected and no hosted session is active; +- ANSI output is disabled (`NO_COLOR`, `TERM=dumb`, explicit `AnsiMode.Never`, ...); +- a command is streaming raw protocol bytes (protocol passthrough, including MCP stdio). + +## Backend selection + +The generic backend is OSC 133, understood by Windows Terminal, WezTerm, iTerm2, Ghostty, and others. When the VS Code integrated terminal is detected — `TERM_PROGRAM=vscode` locally, or a hosted session reporting a `vscode` terminal identity — Repl switches to the OSC 633 dialect and additionally reports the command line with `E`. + +ConEmu is deliberately excluded from `Auto`: it renders OSC 9;4 progress but not FinalTerm marks. + +## Hosted sessions + +Hosted sessions (WebSocket, Telnet) receive marks when their reported terminal identity infers `TerminalCapabilities.ShellIntegrationMarks` (for example `Windows Terminal`, `wezterm`, `vscode`) or when the host sets the flag explicitly through `TerminalSessionOverrides`. See [Terminal Metadata](terminal-metadata.md). + +## See Also + +- [Interactive Loop](interactive-loop.md) — where the marks sit in the prompt cycle +- [Progress](progress.md#advanced-terminal-progress) — the OSC 9;4 progress integration +- [Terminal Metadata](terminal-metadata.md) — capability flags and how sessions advertise them +- [Configuration Reference](configuration-reference.md) — all options From f6bee0faa6e77c3cb18b7b27f77eb721e49cbb92 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 13:54:58 -0400 Subject: [PATCH 06/32] fix: keep server environment out of hosted mark decisions Address review feedback on the shell-integration marks: - Auto mode and 633-backend selection now respect the session boundary: hosted sessions rely solely on the client-advertised capability and terminal identity; WT_SESSION/TERM_PROGRAM describe the terminal the server runs in and no longer leak marks (or the VS Code dialect) to remote clients that never advertised support. - A failed interactive 'complete' ambient command now propagates HandledError so the command-end mark reports exit code 1 instead of decorating the failure as success. - Regression tests for both: server-env suppression, backend selection under a vscode server env, and the complete-failure exit code. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- docs/terminal-shell-integration.md | 4 +-- src/Repl.Core/Session/InteractiveSession.cs | 4 +-- .../Terminal/ShellIntegrationMarkEmitter.cs | 17 +++++---- ...nteractiveSession_ShellIntegrationMarks.cs | 36 ++++++++++++++----- .../Given_ShellIntegrationMarkEmitter.cs | 35 +++++++++++++----- 5 files changed, 69 insertions(+), 27 deletions(-) diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index 3862c48..02cfb07 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -39,7 +39,7 @@ CLI one-shot mode emits no marks: Repl does not own the surrounding shell prompt `ShellIntegrationMode` mirrors the existing `AdvancedProgressMode` semantics: -- `Auto` (default) — emit when the terminal is known to render marks: the hosted session advertises `TerminalCapabilities.ShellIntegrationMarks`, or the local environment identifies Windows Terminal (`WT_SESSION`), VS Code (`TERM_PROGRAM=vscode`), or WezTerm (`TERM_PROGRAM=WezTerm`). Multiplexers (tmux, GNU screen) stay off: mark positioning is unreliable through panes. +- `Auto` (default) — emit when the terminal is known to render marks. For a hosted session, only what the remote client advertised counts: `TerminalCapabilities.ShellIntegrationMarks` (usually inferred from its reported terminal identity). For the local console, the environment identifies Windows Terminal (`WT_SESSION`), VS Code (`TERM_PROGRAM=vscode`), or WezTerm (`TERM_PROGRAM=WezTerm`); multiplexers (tmux, GNU screen) stay off because mark positioning is unreliable through panes. The server's own environment never enables marks for remote clients. - `Always` — emit whenever the structural gates allow it (see below). Useful for terminals that render marks but are not auto-detected, such as iTerm2 reached over SSH. - `Never` — never emit. @@ -51,7 +51,7 @@ Regardless of mode, marks are never written when: ## Backend selection -The generic backend is OSC 133, understood by Windows Terminal, WezTerm, iTerm2, Ghostty, and others. When the VS Code integrated terminal is detected — `TERM_PROGRAM=vscode` locally, or a hosted session reporting a `vscode` terminal identity — Repl switches to the OSC 633 dialect and additionally reports the command line with `E`. +The generic backend is OSC 133, understood by Windows Terminal, WezTerm, iTerm2, Ghostty, and others. When the VS Code integrated terminal is detected — `TERM_PROGRAM=vscode` for the local console, or a `vscode` terminal identity reported by the hosted session's client — Repl switches to the OSC 633 dialect and additionally reports the command line with `E`. Backend selection follows the same session boundary as `Auto`: the server's environment never picks the dialect for a remote client. ConEmu is deliberately excluded from `Auto`: it renders OSC 9;4 progress but not FinalTerm marks. diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 884c33f..b6fa0ee 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -321,13 +321,13 @@ internal async ValueTask TryHandleAmbientCommandAsync( if (string.Equals(token, "complete", StringComparison.OrdinalIgnoreCase)) { - _ = await HandleCompletionAmbientCommandAsync( + var completionSucceeded = await HandleCompletionAmbientCommandAsync( inputTokens.Skip(1).ToArray(), scopeTokens, serviceProvider, cancellationToken) .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; + return completionSucceeded ? AmbientCommandOutcome.Handled : AmbientCommandOutcome.HandledError; } if (string.Equals(token, "autocomplete", StringComparison.OrdinalIgnoreCase)) diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 57f08e8..5ad3531 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -166,22 +166,27 @@ private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOp return false; } + // Hosted sessions decide from what the remote client advertised, never from the + // server's own environment: WT_SESSION/TERM_PROGRAM describe the terminal the + // server runs in, not the WebSocket/Telnet client on the other end. return options.ShellIntegration switch { ShellIntegrationMode.Always => true, ShellIntegrationMode.Never => false, - _ => SessionAdvertisesShellIntegration() || TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal(), + _ when ReplSessionIO.IsSessionActive => SessionAdvertisesShellIntegration(), + _ => TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal(), }; } private static bool SessionAdvertisesShellIntegration() => - ReplSessionIO.IsSessionActive - && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ShellIntegrationMarks); + ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ShellIntegrationMarks); + // Same session-boundary rule as ResolveEnabled: the 633 dialect is chosen from the + // client-reported identity for hosted sessions, from the environment locally. private static bool IsVsCodeBackend() => - TerminalEnvironmentClassifier.IsVsCodeTerminal() - || (ReplSessionIO.IsSessionActive - && ReplSessionIO.TerminalIdentity?.Contains("vscode", StringComparison.OrdinalIgnoreCase) is true); + ReplSessionIO.IsSessionActive + ? ReplSessionIO.TerminalIdentity?.Contains("vscode", StringComparison.OrdinalIgnoreCase) is true + : TerminalEnvironmentClassifier.IsVsCodeTerminal(); // The escape set is backslash, semicolon, and every control character below 0x20; // built programmatically to keep raw control bytes out of the source file. diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index b1a81cd..152e45e 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -152,20 +152,15 @@ public void When_HandlerThrowsOperationCanceled_Then_CancelledLineIsPrintedAndEx } [TestMethod] - [Description("Under the VS Code integrated terminal the OSC 633 backend reports the committed command line with 633;E so command detection is independent of screen scraping.")] + [Description("A session reporting a VS Code terminal identity selects the OSC 633 backend, which reports the committed command line with 633;E so command detection is independent of screen scraping.")] public void When_VsCodeTerminalIsDetected_Then_CommandLineIsReportedWithOsc633E() { - using var env = new EnvironmentVariableScope( - ("TMUX", null), - ("TERM", null), - ("WT_SESSION", null), - ("ConEmuANSI", null), - ("TERM_PROGRAM", "vscode")); + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(ShellIntegrationMode.Auto); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); - var raw = RunInteractiveSession(harness, sut, "ping\rexit\r"); + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r", terminalIdentity: "vscode"); var inputStart = raw.IndexOf("]633;B", StringComparison.Ordinal); var commandLine = raw.IndexOf("]633;E;ping", StringComparison.Ordinal); @@ -176,6 +171,21 @@ public void When_VsCodeTerminalIsDetected_Then_CommandLineIsReportedWithOsc633E( raw.Should().NotContain("]133;"); } + [TestMethod] + [Description("A failed completion ambient command (complete without --target) reports exit code 1 in the command-end mark instead of decorating the failure as success.")] + public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "complete\rexit\r"); + + raw.Should().Contain("Error: complete requires --target"); + raw.Should().Contain("]133;D;1"); + } + [TestMethod] [Description("Without UseTerminalIntegration the interactive loop emits no shell-integration marks at all: the feature is opt-in.")] public void When_IntegrationIsNotConfigured_Then_NoMarksAreEmitted() @@ -225,7 +235,11 @@ private static ReplApp CreateMarkedApp(ShellIntegrationMode mode = ShellIntegrat return sut; } - private static string RunInteractiveSession(TerminalHarness harness, ReplApp sut, string typedInput) + private static string RunInteractiveSession( + TerminalHarness harness, + ReplApp sut, + string typedInput, + string? terminalIdentity = null) { var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); var previousReader = ReplSessionIO.KeyReader; @@ -236,6 +250,10 @@ private static string RunInteractiveSession(TerminalHarness harness, ReplApp sut ReplSessionIO.WindowSize = (harness.Cols, harness.Rows); ReplSessionIO.AnsiSupport = true; ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; + if (terminalIdentity is not null) + { + ReplSessionIO.TerminalIdentity = terminalIdentity; + } _ = sut.Run([]); return harness.RawOutput; diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index d15f05e..23235da 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -67,15 +67,15 @@ public async Task When_ModeNever_Then_NoMarksAreEmitted() } [TestMethod] - [Description("Auto mode emits OSC 133 marks when Windows Terminal is detected through WT_SESSION.")] - public async Task When_ModeAuto_AndWindowsTerminalDetected_Then_Osc133MarksAreEmitted() + [Description("Auto mode ignores the server process environment while a hosted session is active: a client that never advertised marks gets none even when the server runs inside Windows Terminal or VS Code.")] + public async Task When_ModeAuto_AndHostedSessionLacksCapability_Then_ServerEnvironmentDoesNotEnableMarks() { using var env = new EnvironmentVariableScope( ("TMUX", null), ("TERM", null), ("WT_SESSION", "test-session"), ("ConEmuANSI", null), - ("TERM_PROGRAM", null)); + ("TERM_PROGRAM", "vscode")); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -85,14 +85,13 @@ public async Task When_ModeAuto_AndWindowsTerminalDetected_Then_Osc133MarksAreEm await RunFullLifecycleAsync(emitter); - harness.RawOutput.Should().Contain("]133;A"); - harness.RawOutput.Should().Contain("]133;D;0"); - harness.RawOutput.Should().NotContain("]633;"); + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); } [TestMethod] - [Description("Auto mode selects the OSC 633 backend under VS Code (TERM_PROGRAM=vscode) and reports the command line with 633;E between input start and output start.")] - public async Task When_ModeAuto_AndVsCodeDetected_Then_Osc633MarksAreEmittedIncludingCommandLine() + [Description("Backend selection ignores the server process environment for hosted sessions: a client advertising generic marks capability gets OSC 133 even when the server runs inside VS Code.")] + public async Task When_HostedSessionAdvertisesMarks_AndServerRunsInsideVsCode_Then_GenericBackendIsUsed() { using var env = new EnvironmentVariableScope( ("TMUX", null), @@ -105,6 +104,26 @@ public async Task When_ModeAuto_AndVsCodeDetected_Then_Osc633MarksAreEmittedIncl output: harness.Writer, input: TextReader.Null, ansiMode: AnsiMode.Always); + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().Contain("]133;A"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [Description("A session reporting a VS Code identity selects the OSC 633 backend and reports the command line with 633;E between input start and output start.")] + public async Task When_ModeAuto_AndVsCodeDetected_Then_Osc633MarksAreEmittedIncludingCommandLine() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + ReplSessionIO.TerminalIdentity = "vscode"; var emitter = CreateEmitter(ShellIntegrationMode.Auto); await emitter.WritePromptStartAsync(); From 8c066a83666a8b3c54972d6e56cda5340466f697 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 15:11:00 -0400 Subject: [PATCH 07/32] fix: close mark lifecycle on every dispatch path Address second-round review feedback: - Protocol-passthrough routes run interactively no longer get output marks: the input is pre-resolved before opening the output region, and the cycle is abandoned silently (no D after the payload; the next prompt-start implicitly closes it on the terminal side). - A dispatch that throws (e.g. history with a non-numeric --limit) now emits D;1 before the exception propagates, so the terminal never keeps an unterminated command segment. - A failed ambient help render (unknown output format) propagates HandledError and reports D;1, matching the non-ambient --help path. - Committed-input handling extracted to ExecuteCommittedInputAsync to keep the session loop within analyzer limits. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 81 +++++++++++++++++-- .../Terminal/ShellIntegrationMarkEmitter.cs | 7 ++ ...nteractiveSession_ShellIntegrationMarks.cs | 61 +++++++++++++- 3 files changed, 140 insertions(+), 9 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index b6fa0ee..ab196dc 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -79,12 +79,9 @@ internal async ValueTask RunInteractiveSessionAsync( cancellationToken) .ConfigureAwait(false); - await marks.WriteCommandLineAsync(line).ConfigureAwait(false); - await marks.WriteOutputStartAsync().ConfigureAwait(false); - var (outcome, exitCode) = await DispatchInteractiveCommandAsync( - inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + var outcome = await ExecuteCommittedInputAsync( + marks, line, inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); - await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); if (outcome == AmbientCommandOutcome.Exit) { return 0; @@ -212,6 +209,76 @@ private async ValueTask ExecuteWithCancellationAsync( } } + /// + /// Runs one committed input line inside its shell-integration lifecycle: opens the + /// output region, dispatches, and guarantees the cycle is closed on every path. + /// + private async ValueTask ExecuteCommittedInputAsync( + ShellIntegrationMarkEmitter marks, + string line, + List inputTokens, + List scopeTokens, + IServiceProvider serviceProvider, + CancelKeyHandler cancelHandler, + CancellationToken cancellationToken) + { + // A protocol-passthrough command turns the output stream into a protocol + // channel: no mark may precede or trail its payload. A/B were already + // written around the prompt (before the command was known); the cycle is + // abandoned silently and the next prompt-start implicitly closes it. + var isProtocolPassthrough = IsProtocolPassthroughInvocation(inputTokens, scopeTokens); + if (!isProtocolPassthrough) + { + await marks.WriteCommandLineAsync(line).ConfigureAwait(false); + await marks.WriteOutputStartAsync().ConfigureAwait(false); + } + + AmbientCommandOutcome outcome; + int exitCode; + try + { + (outcome, exitCode) = await DispatchInteractiveCommandAsync( + inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + .ConfigureAwait(false); + } + catch when (!isProtocolPassthrough) + { + // Close the lifecycle before the exception propagates so the terminal + // never keeps an unterminated command segment. + await marks.WriteCommandEndAsync(exitCode: 1).ConfigureAwait(false); + throw; + } + + if (isProtocolPassthrough) + { + marks.AbandonCycle(); + } + else + { + await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); + } + + return outcome; + } + + /// + /// Pre-resolves the typed input (without executing it) to detect protocol-passthrough + /// routes before any lifecycle mark opens the output region. + /// + private bool IsProtocolPassthroughInvocation(List inputTokens, List scopeTokens) + { + var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); + var globalOptions = GlobalOptionParser.Parse(invocationTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); + var prefixResolution = app.ResolveUniquePrefixes(globalOptions.RemainingTokens); + if (prefixResolution.IsAmbiguous) + { + return false; + } + + var match = app.Resolve(prefixResolution.Tokens); + return match?.Route.Command.IsProtocolPassthrough == true; + } + private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, CancellationToken ct) { if (serviceProvider.GetService(typeof(IReplInteractionChannel)) is ICommandTokenReceiver receiver) @@ -305,8 +372,8 @@ internal async ValueTask TryHandleAmbientCommandAsync( helpTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); - _ = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); - return AmbientCommandOutcome.Handled; + var helpRendered = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); + return helpRendered ? AmbientCommandOutcome.Handled : AmbientCommandOutcome.HandledError; } if (inputTokens.Count == 1 && string.Equals(token, "..", StringComparison.Ordinal)) diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 5ad3531..14a66a4 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -96,6 +96,13 @@ public async ValueTask WriteOutputStartAsync() await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};C{Bell}").ConfigureAwait(false); } + /// + /// Closes the current cycle without writing anything. Used for protocol-passthrough + /// commands, where a trailing D mark would land in the protocol stream; the next + /// prompt-start mark implicitly aborts the unterminated cycle on the terminal side. + /// + public void AbandonCycle() => _phase = Phase.Idle; + /// /// Command end (mark D): call once per prompt cycle. Pass null for aborted or /// empty input (FinalTerm "command aborted" form, no exit-code parameter). No-op when diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 152e45e..b2df88e 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -186,6 +186,54 @@ public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() raw.Should().Contain("]133;D;1"); } + [TestMethod] + [Description("An ambient help invocation that fails to render (unknown output format) reports exit code 1 in the command-end mark, matching the non-ambient --help path.")] + public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsExitCodeOne() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "help --output:bogus\rexit\r"); + + raw.Should().Contain("]133;D;1"); + } + + [TestMethod] + [Description("A protocol-passthrough command run interactively gets no output-start or command-end marks: OSC bytes must never precede or trail a protocol payload on the same stream.")] + public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksWrapTheProtocolStream() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); + + raw.Should().Contain("protocol-payload"); + CountOccurrences(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + CountOccurrences(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the protocol payload"); + raw.IndexOf("]133;C", StringComparison.Ordinal) + .Should().BeGreaterThan( + raw.IndexOf("protocol-payload", StringComparison.Ordinal), + because: "the only output-start mark belongs to the later exit command"); + } + + [TestMethod] + [Description("A dispatch that throws (history with a non-numeric --limit) still closes the lifecycle with a failed command-end mark so the terminal never keeps an unterminated command segment.")] + public void When_AmbientCommandThrows_Then_CommandEndStillReportsFailure() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "history --limit abc\r", swallowRunExceptions: true); + + raw.Should().Contain("]133;D;1"); + } + [TestMethod] [Description("Without UseTerminalIntegration the interactive loop emits no shell-integration marks at all: the feature is opt-in.")] public void When_IntegrationIsNotConfigured_Then_NoMarksAreEmitted() @@ -239,7 +287,8 @@ private static string RunInteractiveSession( TerminalHarness harness, ReplApp sut, string typedInput, - string? terminalIdentity = null) + string? terminalIdentity = null, + bool swallowRunExceptions = false) { var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); var previousReader = ReplSessionIO.KeyReader; @@ -255,7 +304,15 @@ private static string RunInteractiveSession( ReplSessionIO.TerminalIdentity = terminalIdentity; } - _ = sut.Run([]); + try + { + _ = sut.Run([]); + } + catch when (swallowRunExceptions) + { + // The test asserts on the marks emitted before the crash propagated. + } + return harness.RawOutput; } finally From 7370c10dac5356243bb555fb7bc6a743310b5bb9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 16:20:51 -0400 Subject: [PATCH 08/32] fix: re-evaluate marks per cycle, escape spaces, honor --help Third-round review feedback: - Enablement and backend are re-resolved at each prompt start instead of frozen at session start, so hosted clients that advertise capabilities mid-session (Telnet TTYPE, @@repl:* control messages) get marks from the next cycle. Environment variables are still only consulted when no hosted session is active. - 633;E now escapes spaces too: the VS Code contract requires escaping characters 0x20 and below, so multi-word command lines round-trip. - A passthrough route invoked with --help only renders help; the pre-resolution now treats it as a normal lifecycle (C and D emitted). - Test helper uses a typed catch (code-quality feedback). Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 6 +++ .../Terminal/ShellIntegrationMarkEmitter.cs | 45 ++++++++++++------- ...nteractiveSession_ShellIntegrationMarks.cs | 17 ++++++- .../Given_ShellIntegrationMarkEmitter.cs | 29 ++++++++++-- 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index ab196dc..7b503f8 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -269,6 +269,12 @@ private bool IsProtocolPassthroughInvocation(List inputTokens, List EscapedCommandLineChars = CreateEscapedCommandLineChars(); - private readonly bool _enabled; - private readonly bool _isVsCodeBackend; - private readonly string _oscCode; + private readonly TerminalIntegrationOptions? _options; + private readonly OutputOptions _outputOptions; + private bool _enabled; + private bool _isVsCodeBackend; + private string _oscCode = "133"; private Phase _phase; - private ShellIntegrationMarkEmitter(bool enabled, bool isVsCodeBackend) + private ShellIntegrationMarkEmitter(TerminalIntegrationOptions? options, OutputOptions outputOptions) { - _enabled = enabled; - _isVsCodeBackend = isVsCodeBackend; - _oscCode = isVsCodeBackend ? "633" : "133"; + _options = options; + _outputOptions = outputOptions; } public static ShellIntegrationMarkEmitter Create( @@ -41,13 +42,16 @@ public static ShellIntegrationMarkEmitter Create( OutputOptions outputOptions) { ArgumentNullException.ThrowIfNull(outputOptions); - var enabled = ResolveEnabled(options, outputOptions); - return new ShellIntegrationMarkEmitter(enabled, enabled && IsVsCodeBackend()); + return new ShellIntegrationMarkEmitter(options, outputOptions); } /// Prompt start (mark A): call before writing the prompt text. public async ValueTask WritePromptStartAsync() { + // Hosted clients can advertise capabilities mid-session (Telnet TTYPE, + // @@repl:* control messages), so enablement and backend are re-resolved at + // each prompt and frozen for the cycle to keep its marks consistent. + RefreshCycleConfiguration(); if (!_enabled) { return; @@ -124,8 +128,8 @@ public async ValueTask WriteCommandEndAsync(int? exitCode) /// /// Escapes a command line for the OSC 633;E payload per the VS Code shell-integration - /// spec: \ becomes \\, ; becomes \x3b, and control - /// characters below 0x20 become \xHH (lowercase hex). + /// contract: \ becomes \\, ; becomes \x3b, and characters + /// at or below 0x20 (space and control characters) become \xHH (lowercase hex). /// internal static string EscapeCommandLine(string commandLine) { @@ -148,7 +152,7 @@ internal static string EscapeCommandLine(string commandLine) { builder.Append(@"\x3b"); } - else if (ch < ' ') + else if (ch <= ' ') { builder.Append(@"\x").Append(hexDigits[(ch >> 4) & 0xF]).Append(hexDigits[ch & 0xF]); } @@ -161,6 +165,15 @@ internal static string EscapeCommandLine(string commandLine) return builder.ToString(); } + // Resolves enablement and backend for the cycle that is about to start. Cheap by + // design: environment variables are only consulted when no hosted session is active. + private void RefreshCycleConfiguration() + { + _enabled = ResolveEnabled(_options, _outputOptions); + _isVsCodeBackend = _enabled && IsVsCodeBackend(); + _oscCode = _isVsCodeBackend ? "633" : "133"; + } + private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOptions outputOptions) { // Same structural gates as advanced progress (OSC 9;4): marks must never reach @@ -195,14 +208,14 @@ private static bool IsVsCodeBackend() => ? ReplSessionIO.TerminalIdentity?.Contains("vscode", StringComparison.OrdinalIgnoreCase) is true : TerminalEnvironmentClassifier.IsVsCodeTerminal(); - // The escape set is backslash, semicolon, and every control character below 0x20; - // built programmatically to keep raw control bytes out of the source file. + // The escape set is backslash, semicolon, space, and every control character below + // 0x20; built programmatically to keep raw control bytes out of the source file. private static SearchValues CreateEscapedCommandLineChars() { - Span chars = stackalloc char[34]; + Span chars = stackalloc char[35]; chars[0] = '\\'; chars[1] = ';'; - for (var i = 0; i < 32; i++) + for (var i = 0; i <= 32; i++) { chars[i + 2] = (char)i; } diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index b2df88e..ccc1159 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -220,6 +220,21 @@ public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksW because: "the only output-start mark belongs to the later exit command"); } + [TestMethod] + [Description("Requesting --help on a protocol-passthrough route only renders help, so the normal lifecycle applies: the help cycle gets output-start and a successful command-end mark.")] + public void When_PassthroughRouteRequestsHelp_Then_LifecycleMarksApplyNormally() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 24); + + var raw = RunInteractiveSession(harness, sut, "serve --help\rexit\r"); + + CountOccurrences(raw, "]133;C").Should().Be(2, because: "help rendering is normal terminal output, not a protocol payload"); + CountOccurrences(raw, "]133;D;0").Should().Be(2); + } + [TestMethod] [Description("A dispatch that throws (history with a non-numeric --limit) still closes the lifecycle with a failed command-end mark so the terminal never keeps an unterminated command segment.")] public void When_AmbientCommandThrows_Then_CommandEndStillReportsFailure() @@ -308,7 +323,7 @@ private static string RunInteractiveSession( { _ = sut.Run([]); } - catch when (swallowRunExceptions) + catch (InvalidOperationException) when (swallowRunExceptions) { // The test asserts on the marks emitted before the crash propagated. } diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index 23235da..b824d4c 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -268,17 +268,40 @@ public async Task When_OptionsAreNull_Then_NoMarksAreEmitted() } [TestMethod] - [Description("The 633;E payload escapes backslashes, semicolons, and control characters per the VS Code shell-integration spec so the reported command line round-trips exactly.")] + [Description("The 633;E payload escapes backslashes, semicolons, spaces, and control characters per the VS Code shell-integration contract (0x20 and below) so multi-word command lines round-trip exactly.")] public void When_CommandLineContainsBackslashSemicolonAndControlChars_Then_Osc633PayloadIsEscaped() { ShellIntegrationMarkEmitter.EscapeCommandLine(@"a\b;c" + "\n") .Should().Be(@"a\\b\x3bc\x0a"); - ShellIntegrationMarkEmitter.EscapeCommandLine("plain text stays intact") - .Should().Be("plain text stays intact"); + ShellIntegrationMarkEmitter.EscapeCommandLine("git status") + .Should().Be(@"git\x20status"); + ShellIntegrationMarkEmitter.EscapeCommandLine("single-word-stays-intact") + .Should().Be("single-word-stays-intact"); ShellIntegrationMarkEmitter.EscapeCommandLine("tab\there") .Should().Be(@"tab\x09here"); } + [TestMethod] + [Description("A hosted client advertising ShellIntegrationMarks after the session started (Telnet TTYPE, control messages) gets marks from the next prompt cycle: enablement is re-evaluated per cycle, not frozen at session start.")] + public async Task When_HostedSessionAdvertisesMarksMidSession_Then_MarksAppearOnNextPromptCycle() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await RunFullLifecycleAsync(emitter); + harness.RawOutput.Should().NotContain("]133;", because: "the client has not advertised marks yet"); + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().Contain("]133;A"); + harness.RawOutput.Should().Contain("]133;D;0"); + } + [TestMethod] [Description("An aborted or empty command reports D without an exit-code parameter, matching the FinalTerm 'command aborted' form.")] public async Task When_CommandEndsWithoutExitCode_Then_DIsEmittedWithoutParameter() From a4d945a3c7fdaaeb24452b4fd4f105a1a36514a2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 17:15:03 -0400 Subject: [PATCH 09/32] fix: keep failure marks for passthrough errors, honor hosted ANSI caps Fourth-round review feedback: - A protocol-passthrough invocation that fails (validation error, unsupported host, or a thrown exception) never streams a payload: it now keeps its command-end mark and exit code instead of abandoning the cycle, preserving failure decorations. Only a successful passthrough dispatch abandons the cycle silently. - Hosted clients advertising ANSI purely through capability flags (no AnsiSupport override) no longer lose marks to the server console's redirection fallback; explicit opt-outs (AnsiSupport=false, AnsiMode.Never) still win. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 10 +++++++--- .../Terminal/ShellIntegrationMarkEmitter.cs | 19 +++++++++++++++++- ...nteractiveSession_ShellIntegrationMarks.cs | 15 ++++++++++++++ .../Given_ShellIntegrationMarkEmitter.cs | 20 +++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 7b503f8..6653ac9 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -241,20 +241,24 @@ private async ValueTask ExecuteCommittedInputAsync( inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); } - catch when (!isProtocolPassthrough) + catch { // Close the lifecycle before the exception propagates so the terminal - // never keeps an unterminated command segment. + // never keeps an unterminated command segment. A throwing passthrough + // command never completed a payload, so the mark is safe there too. await marks.WriteCommandEndAsync(exitCode: 1).ConfigureAwait(false); throw; } - if (isProtocolPassthrough) + if (isProtocolPassthrough && exitCode == 0) { marks.AbandonCycle(); } else { + // A failed passthrough invocation (validation error, unsupported host) + // only rendered an error and never streamed a payload: keep its failure + // decoration and command boundary. await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); } diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 27c411d..59562aa 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -180,7 +180,7 @@ private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOp // protocol streams, non-ANSI writers, or redirected local output. if (options is null || ReplSessionIO.IsProtocolPassthrough - || !outputOptions.IsAnsiEnabled() + || !IsAnsiCapableForMarks(outputOptions) || (Console.IsOutputRedirected && !ReplSessionIO.IsSessionActive)) { return false; @@ -201,6 +201,23 @@ private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOp private static bool SessionAdvertisesShellIntegration() => ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ShellIntegrationMarks); + // A hosted client can advertise ANSI purely through capability flags (identity + // inference, control messages, TerminalSessionOverrides) without the AnsiSupport + // override; honor that instead of IsAnsiEnabled's fallback to the server console's + // redirection state. Explicit opt-outs (AnsiSupport=false, AnsiMode.Never) still win. + private static bool IsAnsiCapableForMarks(OutputOptions outputOptions) + { + if (outputOptions.IsAnsiEnabled()) + { + return true; + } + + return ReplSessionIO.IsSessionActive + && ReplSessionIO.AnsiSupport is null + && outputOptions.AnsiMode != AnsiMode.Never + && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.Ansi); + } + // Same session-boundary rule as ResolveEnabled: the 633 dialect is chosen from the // client-reported identity for hosted sessions, from the environment locally. private static bool IsVsCodeBackend() => diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index ccc1159..f03d4b3 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -220,6 +220,21 @@ public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksW because: "the only output-start mark belongs to the later exit command"); } + [TestMethod] + [Description("A protocol-passthrough invocation that fails validation (unknown option) never streams a payload: the failure keeps its command-end mark and exit code so terminals decorate it as failed.")] + public void When_PassthroughCommandFailsValidation_Then_CommandEndReportsFailure() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve --bogus 42\rexit\r"); + + raw.Should().NotContain("protocol-payload"); + raw.Should().Contain("]133;D;1"); + } + [TestMethod] [Description("Requesting --help on a protocol-passthrough route only renders help, so the normal lifecycle applies: the help cycle gets output-start and a successful command-end mark.")] public void When_PassthroughRouteRequestsHelp_Then_LifecycleMarksApplyNormally() diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index b824d4c..000ac27 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -281,6 +281,26 @@ public void When_CommandLineContainsBackslashSemicolonAndControlChars_Then_Osc63 .Should().Be(@"tab\x09here"); } + [TestMethod] + [Description("A hosted client advertising ANSI only through capability flags (no AnsiSupport override) still gets marks: the server console's redirection state must not suppress a capability the client explicitly advertised.")] + public async Task When_HostedClientAdvertisesAnsiThroughCapabilities_Then_MarksAreEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null); + ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.ShellIntegrationMarks; + var emitter = ShellIntegrationMarkEmitter.Create( + new TerminalIntegrationOptions { ShellIntegration = ShellIntegrationMode.Auto }, + new OutputOptions()); + + await RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().Contain("]133;A"); + harness.RawOutput.Should().Contain("]133;D;0"); + } + [TestMethod] [Description("A hosted client advertising ShellIntegrationMarks after the session started (Telnet TTYPE, control messages) gets marks from the next prompt cycle: enablement is re-evaluated per cycle, not frozen at session start.")] public async Task When_HostedSessionAdvertisesMarksMidSession_Then_MarksAppearOnNextPromptCycle() From b92096458602489541a76419918900f676f0de65 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 18:25:37 -0400 Subject: [PATCH 10/32] fix: always abandon dispatched passthrough cycles, ambient-first classification Fifth-round review feedback: - Once a passthrough invocation dispatches, no command-end mark is emitted regardless of exit code or exception: handlers may emit raw bytes and then fail (e.g. Results.Exit(7)), and an exit code cannot prove the payload never started. This supersedes the previous validation-error decoration in favor of protocol integrity. - Passthrough classification now rules out ambient commands first: an ambient command sharing its token with a passthrough route (help, history, custom ambient) keeps the normal lifecycle. Token-only mirror helper cross-referenced with the ambient dispatch table. - Red-first regression tests for all three scenarios. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 44 ++++++++++++++++--- ...nteractiveSession_ShellIntegrationMarks.cs | 38 ++++++++++++++-- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 6653ac9..35b7395 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -241,24 +241,28 @@ private async ValueTask ExecuteCommittedInputAsync( inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); } + catch when (isProtocolPassthrough) + { + marks.AbandonCycle(); + throw; + } catch { // Close the lifecycle before the exception propagates so the terminal - // never keeps an unterminated command segment. A throwing passthrough - // command never completed a payload, so the mark is safe there too. + // never keeps an unterminated command segment. await marks.WriteCommandEndAsync(exitCode: 1).ConfigureAwait(false); throw; } - if (isProtocolPassthrough && exitCode == 0) + if (isProtocolPassthrough) { + // Once a passthrough invocation has dispatched, an exit code cannot prove + // the payload never started (handlers may emit bytes and then fail), so + // no mark may trail it — whatever the outcome. marks.AbandonCycle(); } else { - // A failed passthrough invocation (validation error, unsupported host) - // only rendered an error and never streamed a payload: keep its failure - // decoration and command boundary. await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); } @@ -271,6 +275,13 @@ private async ValueTask ExecuteCommittedInputAsync( /// private bool IsProtocolPassthroughInvocation(List inputTokens, List scopeTokens) { + if (IsAmbientCommandInvocation(inputTokens)) + { + // Ambient commands win over routes sharing the same token and produce + // normal terminal output, never a protocol payload. + return false; + } + var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); var globalOptions = GlobalOptionParser.Parse(invocationTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); if (globalOptions.HelpRequested) @@ -289,6 +300,27 @@ private bool IsProtocolPassthroughInvocation(List inputTokens, List + /// Token-only mirror of the dispatch table in : + /// keep the two in sync when adding an ambient command. + /// + private bool IsAmbientCommandInvocation(List inputTokens) + { + if (inputTokens.Count == 0) + { + return false; + } + + var token = inputTokens[0]; + return CoreReplApp.IsHelpToken(token) + || (inputTokens.Count == 1 && string.Equals(token, "..", StringComparison.Ordinal)) + || (inputTokens.Count == 1 && string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) + || string.Equals(token, "complete", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "autocomplete", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "history", StringComparison.OrdinalIgnoreCase) + || app.OptionsSnapshot.AmbientCommands.CustomCommands.ContainsKey(token); + } + private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, CancellationToken ct) { if (serviceProvider.GetService(typeof(IReplInteractionChannel)) is ICommandTokenReceiver receiver) diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index f03d4b3..85bafe8 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -221,8 +221,8 @@ public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksW } [TestMethod] - [Description("A protocol-passthrough invocation that fails validation (unknown option) never streams a payload: the failure keeps its command-end mark and exit code so terminals decorate it as failed.")] - public void When_PassthroughCommandFailsValidation_Then_CommandEndReportsFailure() + [Description("Once a protocol-passthrough invocation dispatches, no command-end mark may be emitted even on failure: an error exit cannot prove the payload never started, so the cycle is abandoned silently.")] + public void When_PassthroughCommandFailsValidation_Then_CycleIsAbandonedWithoutMarks() { using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(); @@ -232,7 +232,39 @@ public void When_PassthroughCommandFailsValidation_Then_CommandEndReportsFailure var raw = RunInteractiveSession(harness, sut, "serve --bogus 42\rexit\r"); raw.Should().NotContain("protocol-payload"); - raw.Should().Contain("]133;D;1"); + CountOccurrences(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + CountOccurrences(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); + } + + [TestMethod] + [Description("A passthrough handler that emits bytes and then returns a nonzero exit gets no trailing command-end mark: OSC bytes must never follow a protocol payload, whatever the exit code.")] + public void When_PassthroughHandlerExitsNonZero_Then_NoMarkTrailsThePayload() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("serve", () => Results.Exit(7)).AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); + + raw.Should().NotContain("]133;D;7"); + CountOccurrences(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); + } + + [TestMethod] + [Description("An ambient command that shares its token with a protocol-passthrough route is handled ambient-first and keeps the normal lifecycle: output-start and a command-end mark wrap its terminal output.")] + public void When_AmbientCommandSharesTokenWithPassthroughRoute_Then_AmbientLifecycleApplies() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("history", () => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "history\rexit\r"); + + raw.Should().NotContain("protocol-payload", because: "the ambient history command wins over the route"); + CountOccurrences(raw, "]133;C").Should().Be(2, because: "the ambient command's output is normal terminal output"); + CountOccurrences(raw, "]133;D;0").Should().Be(2); } [TestMethod] From 99bb337137e69e5f1eb77dd164c7688d7302b552 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 19:02:05 -0400 Subject: [PATCH 11/32] fix: escape DEL and C1 controls in OSC 633;E payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-panel security finding (HIGH): EscapeCommandLine only covered backslash, semicolon, and chars <= 0x20. DEL (0x7f) and the C1 controls 0x80-0x9f passed through verbatim — an unescaped ST (U+009C), OSC (U+009D), or CSI (U+009B) in a pasted command line breaks out of the \x1b]633;E;... sequence and lets attacker-supplied text forge terminal control sequences on xterm.js/VTE (OSC 52 clipboard write, title spoof). - Extend the escape predicate and the SearchValues set to cover DEL and 0x80-0x9f (68 chars total). - Red-first regression test embedding DEL/ST/OSC/CSI and a >0x9f accent. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .../Terminal/ShellIntegrationMarkEmitter.cs | 33 ++++++++++++++----- .../Given_ShellIntegrationMarkEmitter.cs | 10 ++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 59562aa..9f8f0a4 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -128,8 +128,10 @@ public async ValueTask WriteCommandEndAsync(int? exitCode) /// /// Escapes a command line for the OSC 633;E payload per the VS Code shell-integration - /// contract: \ becomes \\, ; becomes \x3b, and characters - /// at or below 0x20 (space and control characters) become \xHH (lowercase hex). + /// contract: \ becomes \\, ; becomes \x3b, and every byte + /// that could break out of the OSC string — space and C0 controls (<= 0x20), DEL + /// (0x7f), and the C1 controls (0x80–0x9f, which include the 8-bit ST/OSC/CSI + /// introducers xterm.js and VTE act on) — becomes \xHH (lowercase hex). /// internal static string EscapeCommandLine(string commandLine) { @@ -152,7 +154,7 @@ internal static string EscapeCommandLine(string commandLine) { builder.Append(@"\x3b"); } - else if (ch <= ' ') + else if (IsForbiddenControl(ch)) { builder.Append(@"\x").Append(hexDigits[(ch >> 4) & 0xF]).Append(hexDigits[ch & 0xF]); } @@ -165,6 +167,11 @@ internal static string EscapeCommandLine(string commandLine) return builder.ToString(); } + // Space + C0 controls, DEL, and C1 controls: all can terminate or forge the OSC + // string on terminals that decode 8-bit control codes. Only reached for chars <= 0x9f + // (nothing above is in the SearchValues set), so the upper bound is implicit. + private static bool IsForbiddenControl(char ch) => ch <= ' ' || ch == '\x7f' || ch >= '\x80'; + // Resolves enablement and backend for the cycle that is about to start. Cheap by // design: environment variables are only consulted when no hosted session is active. private void RefreshCycleConfiguration() @@ -225,16 +232,26 @@ private static bool IsVsCodeBackend() => ? ReplSessionIO.TerminalIdentity?.Contains("vscode", StringComparison.OrdinalIgnoreCase) is true : TerminalEnvironmentClassifier.IsVsCodeTerminal(); - // The escape set is backslash, semicolon, space, and every control character below - // 0x20; built programmatically to keep raw control bytes out of the source file. + // The escape set is backslash, semicolon, space + C0 controls (0x00–0x20), DEL (0x7f), + // and the C1 controls (0x80–0x9f); built programmatically to keep raw control bytes + // out of the source file. Chars above 0x9f are never in the set, so EscapeCommandLine's + // forbidden-control check needs no explicit upper bound. private static SearchValues CreateEscapedCommandLineChars() { - Span chars = stackalloc char[35]; + // 2 punctuation + 33 (0x00–0x20) + 1 (DEL) + 32 (0x80–0x9f) = 68. + Span chars = stackalloc char[68]; chars[0] = '\\'; chars[1] = ';'; - for (var i = 0; i <= 32; i++) + var next = 2; + for (var i = 0; i <= 0x20; i++) + { + chars[next++] = (char)i; + } + + chars[next++] = '\x7f'; + for (var i = 0x80; i <= 0x9f; i++) { - chars[i + 2] = (char)i; + chars[next++] = (char)i; } return SearchValues.Create(chars); diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index 000ac27..7b03e2d 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -279,6 +279,16 @@ public void When_CommandLineContainsBackslashSemicolonAndControlChars_Then_Osc63 .Should().Be("single-word-stays-intact"); ShellIntegrationMarkEmitter.EscapeCommandLine("tab\there") .Should().Be(@"tab\x09here"); + + // DEL (0x7f) and C1 controls (0x80-0x9f) must be escaped too: an unescaped + // ST (U+009C) / OSC (U+009D) / CSI (U+009B) in pasted text would break out of + // the 633;E payload and forge terminal sequences on xterm.js/VTE. + ShellIntegrationMarkEmitter.EscapeCommandLine("del" + (char)0x7f + "here") + .Should().Be(@"del\x7fhere"); + ShellIntegrationMarkEmitter.EscapeCommandLine("st" + (char)0x9c + "osc" + (char)0x9d + "csi" + (char)0x9b) + .Should().Be(@"st\x9cosc\x9dcsi\x9b"); + ShellIntegrationMarkEmitter.EscapeCommandLine("high" + (char)0xe9 + "accent-stays") + .Should().Be("high" + (char)0xe9 + "accent-stays"); } [TestMethod] From 6f9574d89df35cf91a4232432dc499db8a78f381 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 19:26:41 -0400 Subject: [PATCH 12/32] refactor: resolve committed input once; harden mark-cleanup catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-panel findings (HIGH H2, MEDIUM M1/M2). H2 (flagged by architect/skeptic/security/quality/style): the passthrough pre-check parsed and resolved the input independently of dispatch, each calling ResolveActiveRoutingGraph separately — a concurrent routing-graph invalidation between them could make the pre-check say 'not passthrough' while dispatch matched a passthrough route, emitting a C mark + 633;E payload into a protocol stream. Now a single ResolveCommittedInput captures one graph snapshot + one parse into a CommittedResolution record reused by both the mark decision and dispatch (which no longer re-parses or re-resolves). Removes the double/triple parse besides closing the race. M2 (architect/skeptic/quality/style): IsAmbientCommandInvocation was a hand-maintained token mirror of the dispatch table. It is now the single classification authority — TryHandleAmbientCommandAsync guards on it, so the two can never disagree. M1 (operability/skeptic): the cleanup command-end write on the exception path is now best-effort (TryWriteCommandEndAsync) so a torn-down transport failing the D-mark write can no longer mask the original dispatch exception; host-shutdown OperationCanceledException closes the cycle with an aborted D (no exit code) instead of a failure D;1. - Red-first test: a failing mark write surfaces the original exception. - Characterization test: passthrough route with a leading global option. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 149 ++++++++++++------ ...nteractiveSession_ShellIntegrationMarks.cs | 93 +++++++++++ 2 files changed, 197 insertions(+), 45 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 35b7395..5ca2628 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -138,49 +138,44 @@ internal async ValueTask RunInteractiveSessionAsync( } private async ValueTask<(AmbientCommandOutcome Outcome, int ExitCode)> DispatchInteractiveCommandAsync( + CommittedResolution resolution, List inputTokens, List scopeTokens, IServiceProvider serviceProvider, CancelKeyHandler cancelHandler, CancellationToken cancellationToken) { - var ambientOutcome = await TryHandleAmbientCommandAsync( - inputTokens, - scopeTokens, - serviceProvider, - isInteractiveSession: true, - cancellationToken) - .ConfigureAwait(false); - if (ambientOutcome is AmbientCommandOutcome.Exit or AmbientCommandOutcome.Handled) + if (resolution.Kind == CommittedKind.Ambient) { - return (ambientOutcome, 0); + var ambientOutcome = await TryHandleAmbientCommandAsync( + inputTokens, + scopeTokens, + serviceProvider, + isInteractiveSession: true, + cancellationToken) + .ConfigureAwait(false); + return (ambientOutcome, ambientOutcome == AmbientCommandOutcome.HandledError ? 1 : 0); } - if (ambientOutcome is AmbientCommandOutcome.HandledError) - { - return (ambientOutcome, 1); - } - - var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); - var globalOptions = GlobalOptionParser.Parse(invocationTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); - app.GlobalOptionsSnapshotInstance.Update(globalOptions.CustomGlobalNamedOptions); - var prefixResolution = app.ResolveUniquePrefixes(globalOptions.RemainingTokens); - if (prefixResolution.IsAmbiguous) + app.GlobalOptionsSnapshotInstance.Update(resolution.Options!.CustomGlobalNamedOptions); + if (resolution.Kind == CommittedKind.Ambiguous) { + var prefixResolution = app.ResolveUniquePrefixes(resolution.Options.RemainingTokens); var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(prefixResolution); - _ = await app.RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken, isInteractive: true) + _ = await app.RenderOutputAsync(ambiguous, resolution.Options.OutputFormat, cancellationToken, isInteractive: true) .ConfigureAwait(false); return (AmbientCommandOutcome.Handled, 1); } - var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - var exitCode = await ExecuteWithCancellationAsync(resolvedOptions, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + // Help or Routed: both flow through the command-cancellation scope so Ctrl-C and + // exit-code computation behave identically; the pre-resolved graph/match is reused. + var exitCode = await ExecuteWithCancellationAsync(resolution, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); return (AmbientCommandOutcome.Handled, exitCode); } private async ValueTask ExecuteWithCancellationAsync( - GlobalInvocationOptions resolvedOptions, + CommittedResolution resolution, List scopeTokens, IServiceProvider serviceProvider, CancelKeyHandler cancelHandler, @@ -192,14 +187,17 @@ private async ValueTask ExecuteWithCancellationAsync( try { - return await ExecuteInteractiveInputAsync(resolvedOptions, scopeTokens, serviceProvider, commandCts.Token) + return await ExecuteInteractiveInputAsync(resolution, scopeTokens, serviceProvider, commandCts.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { await ReplSessionIO.Output.WriteLineAsync("Cancelled.").ConfigureAwait(false); // 128 + SIGINT(2): the shell convention for an interrupted command, so - // shell-integration marks decorate it as interrupted rather than failed. + // shell-integration marks decorate it as interrupted rather than failed. An + // outer-token cancellation (host shutdown) is NOT matched here — it propagates + // to ExecuteCommittedInputAsync's OCE catch, which closes the cycle with an + // aborted D (no exit code) rather than a failure. return 130; } finally @@ -212,6 +210,9 @@ private async ValueTask ExecuteWithCancellationAsync( /// /// Runs one committed input line inside its shell-integration lifecycle: opens the /// output region, dispatches, and guarantees the cycle is closed on every path. + /// The input is resolved exactly once () and that + /// single result drives both the passthrough mark decision and dispatch, so the two + /// can never disagree across a concurrent routing-graph invalidation. /// private async ValueTask ExecuteCommittedInputAsync( ShellIntegrationMarkEmitter marks, @@ -222,11 +223,13 @@ private async ValueTask ExecuteCommittedInputAsync( CancelKeyHandler cancelHandler, CancellationToken cancellationToken) { + var resolution = ResolveCommittedInput(inputTokens, scopeTokens); + // A protocol-passthrough command turns the output stream into a protocol // channel: no mark may precede or trail its payload. A/B were already // written around the prompt (before the command was known); the cycle is // abandoned silently and the next prompt-start implicitly closes it. - var isProtocolPassthrough = IsProtocolPassthroughInvocation(inputTokens, scopeTokens); + var isProtocolPassthrough = resolution.IsProtocolPassthrough; if (!isProtocolPassthrough) { await marks.WriteCommandLineAsync(line).ConfigureAwait(false); @@ -238,7 +241,7 @@ private async ValueTask ExecuteCommittedInputAsync( try { (outcome, exitCode) = await DispatchInteractiveCommandAsync( - inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + resolution, inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) .ConfigureAwait(false); } catch when (isProtocolPassthrough) @@ -246,11 +249,19 @@ private async ValueTask ExecuteCommittedInputAsync( marks.AbandonCycle(); throw; } + catch (OperationCanceledException) + { + // Host-shutdown / session cancellation: close the cycle without an exit code + // (aborted form) rather than decorating it as a failure, then propagate. + await TryWriteCommandEndAsync(marks, exitCode: null).ConfigureAwait(false); + throw; + } catch { - // Close the lifecycle before the exception propagates so the terminal - // never keeps an unterminated command segment. - await marks.WriteCommandEndAsync(exitCode: 1).ConfigureAwait(false); + // Close the lifecycle before the exception propagates so the terminal never + // keeps an unterminated command segment. Best-effort: a failing mark write + // (e.g. a torn-down transport) must not replace the original exception. + await TryWriteCommandEndAsync(marks, exitCode: 1).ConfigureAwait(false); throw; } @@ -269,17 +280,56 @@ private async ValueTask ExecuteCommittedInputAsync( return outcome; } + // Best-effort command-end used on exception paths: the original exception is the + // signal that matters, so a mark-write failure here is swallowed rather than masking it. + private static async ValueTask TryWriteCommandEndAsync(ShellIntegrationMarkEmitter marks, int? exitCode) + { + try + { + await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); + } + catch + { + // Intentionally swallowed — see caller. + } + } + + private enum CommittedKind + { + Ambient, + Help, + Ambiguous, + Routed, + } + /// - /// Pre-resolves the typed input (without executing it) to detect protocol-passthrough - /// routes before any lifecycle mark opens the output region. + /// The single resolution of one committed input line: whether it is an ambient + /// command, a help request, an ambiguous prefix, or a resolved route — captured + /// against one routing-graph snapshot and reused by both the mark decision and dispatch. /// - private bool IsProtocolPassthroughInvocation(List inputTokens, List scopeTokens) + private readonly record struct CommittedResolution( + CommittedKind Kind, + GlobalInvocationOptions? Options, + ActiveRoutingGraph? Graph, + RouteResolver.RouteResolutionResult? Routes) + { + public bool IsProtocolPassthrough => + Kind == CommittedKind.Routed && Routes?.Match?.Route.Command.IsProtocolPassthrough == true; + } + + /// + /// Resolves the committed input once, against a single + /// snapshot, so the passthrough classification and the eventual execution can never + /// diverge. Ambient classification uses , the + /// single authority also consulted by . + /// + private CommittedResolution ResolveCommittedInput(IReadOnlyList inputTokens, IReadOnlyList scopeTokens) { if (IsAmbientCommandInvocation(inputTokens)) { // Ambient commands win over routes sharing the same token and produce // normal terminal output, never a protocol payload. - return false; + return new CommittedResolution(CommittedKind.Ambient, Options: null, Graph: null, Routes: null); } var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); @@ -287,24 +337,27 @@ private bool IsProtocolPassthroughInvocation(List inputTokens, List - /// Token-only mirror of the dispatch table in : - /// keep the two in sync when adding an ambient command. + /// Single authority for "is this input handled as an ambient command?", consulted by + /// both and the guard in + /// so the two can never disagree. /// - private bool IsAmbientCommandInvocation(List inputTokens) + private bool IsAmbientCommandInvocation(IReadOnlyList inputTokens) { if (inputTokens.Count == 0) { @@ -330,19 +383,23 @@ private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, C } private async ValueTask ExecuteInteractiveInputAsync( - GlobalInvocationOptions globalOptions, + CommittedResolution committed, List scopeTokens, IServiceProvider serviceProvider, CancellationToken cancellationToken) { - var activeGraph = app.ResolveActiveRoutingGraph(); + var globalOptions = committed.Options!; if (globalOptions.HelpRequested) { var rendered = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); return rendered ? 0 : 1; } - var resolution = app.ResolveWithDiagnostics(globalOptions.RemainingTokens, activeGraph.Routes); + // Reuse the single routing-graph snapshot and route resolution captured in + // ResolveCommittedInput — never re-resolve here (that reopened a TOCTOU window + // against concurrent routing-graph invalidation). + var activeGraph = committed.Graph!.Value; + var resolution = committed.Routes!.Value; var match = resolution.Match; if (match is not null) { @@ -401,8 +458,10 @@ internal async ValueTask TryHandleAmbientCommandAsync( bool isInteractiveSession, CancellationToken cancellationToken) { - if (inputTokens.Count == 0) + if (!IsAmbientCommandInvocation(inputTokens)) { + // Single classification authority (shared with ResolveCommittedInput): if the + // input is not an ambient command, none of the branches below would match it. return AmbientCommandOutcome.NotHandled; } diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 85bafe8..b0fd0dc 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -200,6 +200,21 @@ public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsExitCodeO raw.Should().Contain("]133;D;1"); } + [TestMethod] + [Description("A passthrough route invoked with a leading global option (--json) is still classified as passthrough by the single committed-input resolution, so no output marks wrap its payload.")] + public void When_PassthroughCommandHasGlobalOption_Then_NoOutputMarksWrapThePayload() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve --json\rexit\r"); + + CountOccurrences(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + CountOccurrences(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the protocol payload"); + } + [TestMethod] [Description("A protocol-passthrough command run interactively gets no output-start or command-end marks: OSC bytes must never precede or trail a protocol payload on the same stream.")] public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksWrapTheProtocolStream() @@ -336,6 +351,51 @@ public void When_HandlerWritesInteractionEvents_Then_OnlyLoopPromptsEmitMarks() CountOccurrences(raw, "]133;A").Should().Be(2, because: "only the work and exit prompt cycles may open a lifecycle"); } + [TestMethod] + [Description("When the command-end mark write itself fails (torn-down transport), the original dispatch exception must surface, not the mark-write failure that only happened during cleanup.")] + public void When_CommandEndMarkWriteFails_Then_OriginalExceptionSurfaces() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + // `history --limit abc` raises InvalidOperationException from the ambient handler, + // which propagates to the cleanup catch; the writer then throws on the D-mark write. + var writer = new MarkFailingWriter(harness.Writer, failOn: "]133;D"); + + var thrown = CaptureInteractiveRun(writer, sut, "history --limit abc\r"); + + thrown.Should().BeOfType(); + thrown!.Message.Should().Contain("history --limit must be a positive integer"); + } + + private static Exception? CaptureInteractiveRun(TextWriter writer, ReplApp sut, string typedInput) + { + var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); + var previousReader = ReplSessionIO.KeyReader; + using var scope = ReplSessionIO.SetSession(writer, TextReader.Null); + try + { + ReplSessionIO.KeyReader = keyReader; + ReplSessionIO.WindowSize = (80, 12); + ReplSessionIO.AnsiSupport = true; + ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; + try + { + _ = sut.Run([]); + return null; + } + catch (Exception ex) + { + return ex; + } + } + finally + { + ReplSessionIO.KeyReader = previousReader; + } + } + private static ReplApp CreateMarkedApp(ShellIntegrationMode mode = ShellIntegrationMode.Always) { var sut = ReplApp.Create() @@ -408,4 +468,37 @@ private static int CountOccurrences(string text, string needle) return count; } + + // Delegates to an inner writer but throws when asked to write a fragment (e.g. the + // command-end mark), simulating a transport torn down mid-command. + private sealed class MarkFailingWriter(TextWriter inner, string failOn) : TextWriter + { + public override System.Text.Encoding Encoding => inner.Encoding; + + public override void Write(string? value) + { + ThrowIfFragment(value); + inner.Write(value); + } + + public override Task WriteAsync(string? value) + { + ThrowIfFragment(value); + return inner.WriteAsync(value); + } + + public override Task WriteLineAsync(string? value) + { + ThrowIfFragment(value); + return inner.WriteLineAsync(value); + } + + private void ThrowIfFragment(string? value) + { + if (value is not null && value.Contains(failOn, StringComparison.Ordinal)) + { + throw new IOException("simulated transport failure on mark write"); + } + } + } } From 0e09bfa6d2d8dd526ec07427c2a3c9d857290008 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 19:41:21 -0400 Subject: [PATCH 13/32] refactor: cache mark strings, pin exact framing, dedupe test helpers Review-panel cleanup findings (style/skeptic/contract, LOW): - Precompute the constant A/B/C and D-without-code mark strings per backend (MarkSet) so the per-prompt path allocates no throw-away interpolated strings; only D-with-exit-code still formats. - Add exact ESC/BEL framing tests (\x1b]133;A\x07 ...) so a dropped introducer or a wrong terminator is caught, not just the ]133;X substring. - Delete the private EnvironmentVariableScope copy in the advanced-progress test; use the shared Repl.Tests.TerminalSupport one. - Replace the two duplicated CountOccurrences helpers with a shared TerminalMarks.Count over MemoryExtensions.Count. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .../Terminal/ShellIntegrationMarkEmitter.cs | 25 ++++--- ...plInteractionPresenter_AdvancedProgress.cs | 25 ------- ...nteractiveSession_ShellIntegrationMarks.cs | 37 ++++------ .../Given_ShellIntegrationMarkEmitter.cs | 70 +++++++++++++++---- src/Repl.Tests/Terminal/TerminalMarks.cs | 11 +++ 5 files changed, 95 insertions(+), 73 deletions(-) create mode 100644 src/Repl.Tests/Terminal/TerminalMarks.cs diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 9f8f0a4..ca0db89 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -22,15 +22,22 @@ private enum Phase private const string Bell = "\x07"; + // The A/B/C/D-no-code marks are constant per backend; precomputed so the per-prompt + // path allocates no throw-away interpolated strings (only D-with-exit-code does). + private static readonly MarkSet Osc133 = new("\x1b]133;A\x07", "\x1b]133;B\x07", "\x1b]133;C\x07", "\x1b]133;D\x07"); + private static readonly MarkSet Osc633 = new("\x1b]633;A\x07", "\x1b]633;B\x07", "\x1b]633;C\x07", "\x1b]633;D\x07"); + private static readonly SearchValues EscapedCommandLineChars = CreateEscapedCommandLineChars(); private readonly TerminalIntegrationOptions? _options; private readonly OutputOptions _outputOptions; private bool _enabled; private bool _isVsCodeBackend; - private string _oscCode = "133"; + private MarkSet _marks = Osc133; private Phase _phase; + private readonly record struct MarkSet(string PromptStart, string InputStart, string OutputStart, string CommandEndNoCode); + private ShellIntegrationMarkEmitter(TerminalIntegrationOptions? options, OutputOptions outputOptions) { _options = options; @@ -58,7 +65,7 @@ public async ValueTask WritePromptStartAsync() } _phase = Phase.Prompt; - await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};A{Bell}").ConfigureAwait(false); + await ReplSessionIO.Output.WriteAsync(_marks.PromptStart).ConfigureAwait(false); } /// Prompt end / input start (mark B): call after the prompt text, before reading the line. @@ -70,7 +77,7 @@ public async ValueTask WriteInputStartAsync() } _phase = Phase.Input; - await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};B{Bell}").ConfigureAwait(false); + await ReplSessionIO.Output.WriteAsync(_marks.InputStart).ConfigureAwait(false); } /// @@ -97,7 +104,7 @@ public async ValueTask WriteOutputStartAsync() } _phase = Phase.Executing; - await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};C{Bell}").ConfigureAwait(false); + await ReplSessionIO.Output.WriteAsync(_marks.OutputStart).ConfigureAwait(false); } /// @@ -120,10 +127,10 @@ public async ValueTask WriteCommandEndAsync(int? exitCode) } _phase = Phase.Idle; - var suffix = exitCode is { } code - ? $";{code.ToString(CultureInfo.InvariantCulture)}" - : string.Empty; - await ReplSessionIO.Output.WriteAsync($"\x1b]{_oscCode};D{suffix}{Bell}").ConfigureAwait(false); + var mark = exitCode is { } code + ? $"\x1b]{(_isVsCodeBackend ? "633" : "133")};D;{code.ToString(CultureInfo.InvariantCulture)}{Bell}" + : _marks.CommandEndNoCode; + await ReplSessionIO.Output.WriteAsync(mark).ConfigureAwait(false); } /// @@ -178,7 +185,7 @@ private void RefreshCycleConfiguration() { _enabled = ResolveEnabled(_options, _outputOptions); _isVsCodeBackend = _enabled && IsVsCodeBackend(); - _oscCode = _isVsCodeBackend ? "633" : "133"; + _marks = _isVsCodeBackend ? Osc633 : Osc133; } private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOptions outputOptions) diff --git a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs index d05ffa9..32db76a 100644 --- a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs +++ b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs @@ -123,29 +123,4 @@ await presenter.PresentAsync( harness.RawOutput.Should().Contain("\u001b]9;4;1;42\u0007"); } - - private sealed class EnvironmentVariableScope : IDisposable - { - private readonly (string Name, string? PreviousValue)[] _previousValues; - - public EnvironmentVariableScope(params (string Name, string? Value)[] variables) - { - _previousValues = variables - .Select(static variable => (variable.Name, Environment.GetEnvironmentVariable(variable.Name))) - .ToArray(); - - foreach (var (name, value) in variables) - { - Environment.SetEnvironmentVariable(name, value); - } - } - - public void Dispose() - { - foreach (var (name, previousValue) in _previousValues) - { - Environment.SetEnvironmentVariable(name, previousValue); - } - } - } } diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index b0fd0dc..a21d221 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -211,8 +211,8 @@ public void When_PassthroughCommandHasGlobalOption_Then_NoOutputMarksWrapThePayl var raw = RunInteractiveSession(harness, sut, "serve --json\rexit\r"); - CountOccurrences(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); - CountOccurrences(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the protocol payload"); + TerminalMarks.Count(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + TerminalMarks.Count(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the protocol payload"); } [TestMethod] @@ -227,8 +227,8 @@ public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksW var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); raw.Should().Contain("protocol-payload"); - CountOccurrences(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); - CountOccurrences(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the protocol payload"); + TerminalMarks.Count(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + TerminalMarks.Count(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the protocol payload"); raw.IndexOf("]133;C", StringComparison.Ordinal) .Should().BeGreaterThan( raw.IndexOf("protocol-payload", StringComparison.Ordinal), @@ -247,8 +247,8 @@ public void When_PassthroughCommandFailsValidation_Then_CycleIsAbandonedWithoutM var raw = RunInteractiveSession(harness, sut, "serve --bogus 42\rexit\r"); raw.Should().NotContain("protocol-payload"); - CountOccurrences(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); - CountOccurrences(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); + TerminalMarks.Count(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + TerminalMarks.Count(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); } [TestMethod] @@ -263,7 +263,7 @@ public void When_PassthroughHandlerExitsNonZero_Then_NoMarkTrailsThePayload() var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); raw.Should().NotContain("]133;D;7"); - CountOccurrences(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); + TerminalMarks.Count(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); } [TestMethod] @@ -278,8 +278,8 @@ public void When_AmbientCommandSharesTokenWithPassthroughRoute_Then_AmbientLifec var raw = RunInteractiveSession(harness, sut, "history\rexit\r"); raw.Should().NotContain("protocol-payload", because: "the ambient history command wins over the route"); - CountOccurrences(raw, "]133;C").Should().Be(2, because: "the ambient command's output is normal terminal output"); - CountOccurrences(raw, "]133;D;0").Should().Be(2); + TerminalMarks.Count(raw, "]133;C").Should().Be(2, because: "the ambient command's output is normal terminal output"); + TerminalMarks.Count(raw, "]133;D;0").Should().Be(2); } [TestMethod] @@ -293,8 +293,8 @@ public void When_PassthroughRouteRequestsHelp_Then_LifecycleMarksApplyNormally() var raw = RunInteractiveSession(harness, sut, "serve --help\rexit\r"); - CountOccurrences(raw, "]133;C").Should().Be(2, because: "help rendering is normal terminal output, not a protocol payload"); - CountOccurrences(raw, "]133;D;0").Should().Be(2); + TerminalMarks.Count(raw, "]133;C").Should().Be(2, because: "help rendering is normal terminal output, not a protocol payload"); + TerminalMarks.Count(raw, "]133;D;0").Should().Be(2); } [TestMethod] @@ -348,7 +348,7 @@ public void When_HandlerWritesInteractionEvents_Then_OnlyLoopPromptsEmitMarks() var raw = RunInteractiveSession(harness, sut, "work\rexit\r"); raw.Should().Contain("working on it"); - CountOccurrences(raw, "]133;A").Should().Be(2, because: "only the work and exit prompt cycles may open a lifecycle"); + TerminalMarks.Count(raw, "]133;A").Should().Be(2, because: "only the work and exit prompt cycles may open a lifecycle"); } [TestMethod] @@ -456,19 +456,6 @@ private static ConsoleKey CharToConsoleKey(char ch) => ? ConsoleKey.A + (char.ToUpperInvariant(ch) - 'A') : ConsoleKey.Spacebar; - private static int CountOccurrences(string text, string needle) - { - var count = 0; - var index = 0; - while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) - { - count++; - index += needle.Length; - } - - return count; - } - // Delegates to an inner writer but throws when asked to write a fragment (e.g. the // command-end mark), simulating a transport torn down mid-command. private sealed class MarkFailingWriter(TextWriter inner, string failOn) : TextWriter diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index 7b03e2d..7298058 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -43,6 +43,61 @@ public async Task When_ModeAlways_AndAnsiSession_Then_LifecycleMarksAreEmittedIn commandEnd.Should().BeGreaterThan(outputStart); } + [TestMethod] + [Description("Marks use the exact OSC framing — ESC introducer and BEL terminator — so a dropped ESC or a wrong terminator (ST vs BEL) is caught, not just the ]133;X substring.")] + public async Task When_MarksAreEmitted_Then_FullOscFramingIsExact() + { + var esc = ((char)0x1b).ToString(); + var bel = ((char)0x07).ToString(); + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + await emitter.WritePromptStartAsync(); + await emitter.WriteInputStartAsync(); + await emitter.WriteOutputStartAsync(); + await emitter.WriteCommandEndAsync(exitCode: 0); + + var raw = harness.RawOutput; + raw.Should().Contain(esc + "]133;A" + bel); + raw.Should().Contain(esc + "]133;B" + bel); + raw.Should().Contain(esc + "]133;C" + bel); + raw.Should().Contain(esc + "]133;D;0" + bel); + } + + [TestMethod] + [Description("The VS Code backend uses the same exact ESC/BEL framing on the 633 dialect, including the command-line report and the exit-code-less command end.")] + public async Task When_VsCodeBackend_Then_Full633FramingIsExact() + { + var esc = ((char)0x1b).ToString(); + var bel = ((char)0x07).ToString(); + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + ReplSessionIO.TerminalIdentity = "vscode"; + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await emitter.WritePromptStartAsync(); + await emitter.WriteInputStartAsync(); + await emitter.WriteCommandLineAsync("ping"); + await emitter.WriteOutputStartAsync(); + await emitter.WriteCommandEndAsync(exitCode: null); + + var raw = harness.RawOutput; + raw.Should().Contain(esc + "]633;A" + bel); + raw.Should().Contain(esc + "]633;B" + bel); + raw.Should().Contain(esc + "]633;E;ping" + bel); + raw.Should().Contain(esc + "]633;C" + bel); + raw.Should().Contain(esc + "]633;D" + bel); + } + [TestMethod] [Description("Never mode suppresses every mark even on a capable ANSI session.")] public async Task When_ModeNever_Then_NoMarksAreEmitted() @@ -367,7 +422,7 @@ public async Task When_CommandEndIsCalledTwice_Then_OnlyOneDIsEmitted() await RunFullLifecycleAsync(emitter); await emitter.WriteCommandEndAsync(exitCode: 1); - CountOccurrences(harness.RawOutput, "]133;D").Should().Be(1); + TerminalMarks.Count(harness.RawOutput, "]133;D").Should().Be(1); } [TestMethod] @@ -408,17 +463,4 @@ private static async Task RunFullLifecycleAsync(ShellIntegrationMarkEmitter emit await emitter.WriteOutputStartAsync().ConfigureAwait(false); await emitter.WriteCommandEndAsync(exitCode: 0).ConfigureAwait(false); } - - private static int CountOccurrences(string text, string needle) - { - var count = 0; - var index = 0; - while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) - { - count++; - index += needle.Length; - } - - return count; - } } diff --git a/src/Repl.Tests/Terminal/TerminalMarks.cs b/src/Repl.Tests/Terminal/TerminalMarks.cs new file mode 100644 index 0000000..b7e62d6 --- /dev/null +++ b/src/Repl.Tests/Terminal/TerminalMarks.cs @@ -0,0 +1,11 @@ +namespace Repl.Tests.TerminalSupport; + +/// +/// Test assertions over terminal shell-integration mark output. +/// +internal static class TerminalMarks +{ + /// Counts non-overlapping occurrences of in . + public static int Count(string text, string needle) => + text.AsSpan().Count(needle.AsSpan()); +} From 6ef23ee27dd1c07f3d589198684f8897ca0fc545 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 19:42:38 -0400 Subject: [PATCH 14/32] docs: clarify passthrough marks, add troubleshooting and disable paths Review-panel docs findings (quality/operability, MEDIUM): - Correct the passthrough section: A/B precede a passthrough command (written around the prompt before it is known), then E/C/D are suppressed and the cycle is abandoned regardless of exit code; the next prompt-start closes it. Note the ambient/--help exceptions. - Add a symptom -> gate troubleshooting table so the black-box enablement decision can be triaged without reading ResolveEnabled. - Document the disable paths: ShellIntegrationMode.Never per app, and NO_COLOR/TERM=dumb as the end-user escape hatch (with the all-ANSI collateral called out). Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- docs/terminal-shell-integration.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index 02cfb07..bc3d885 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -49,6 +49,17 @@ Regardless of mode, marks are never written when: - ANSI output is disabled (`NO_COLOR`, `TERM=dumb`, explicit `AnsiMode.Never`, ...); - a command is streaming raw protocol bytes (protocol passthrough, including MCP stdio). +## Protocol-passthrough commands + +A command marked `.AsProtocolPassthrough()` turns the output stream into a raw protocol channel (MCP stdio, a completion payload, a file transfer). No mark may sit inside that stream. Because the prompt marks `A` and `B` are written *around the prompt* — before the committed line is known — they still precede such a command; but once the input resolves to a passthrough route, no `E`, `C`, or `D` is emitted, and the cycle is abandoned. The next prompt's `A` implicitly closes the abandoned segment on the terminal side. This holds whatever the command's exit code: a passthrough handler may emit bytes and then fail, so an exit code can never prove the payload never started. + +An input that only *looks* like it targets a passthrough route but does not actually stream a payload keeps the normal lifecycle: an ambient command sharing the route's token (for example `help`, `history`, or a custom ambient), or ` --help` (which only renders help), all get `C` and `D` as usual. + +## Disabling marks + +- **Per app**: `options.ShellIntegration = ShellIntegrationMode.Never` (or simply never calling `UseTerminalIntegration`). +- **Per run, by the end user**: `NO_COLOR=1` or `TERM=dumb` disables marks — but as collateral of disabling all ANSI styling, since marks ride the same ANSI gate. There is no mark-only runtime switch; if a terminal is misdetected and shows raw `]133;…`, `NO_COLOR` is the escape hatch. + ## Backend selection The generic backend is OSC 133, understood by Windows Terminal, WezTerm, iTerm2, Ghostty, and others. When the VS Code integrated terminal is detected — `TERM_PROGRAM=vscode` for the local console, or a `vscode` terminal identity reported by the hosted session's client — Repl switches to the OSC 633 dialect and additionally reports the command line with `E`. Backend selection follows the same session boundary as `Auto`: the server's environment never picks the dialect for a remote client. @@ -59,6 +70,18 @@ ConEmu is deliberately excluded from `Auto`: it renders OSC 9;4 progress but not Hosted sessions (WebSocket, Telnet) receive marks when their reported terminal identity infers `TerminalCapabilities.ShellIntegrationMarks` (for example `Windows Terminal`, `wezterm`, `vscode`) or when the host sets the flag explicitly through `TerminalSessionOverrides`. See [Terminal Metadata](terminal-metadata.md). +## Troubleshooting + +The enablement decision is a black box at runtime (it emits no diagnostics). When marks misbehave, walk the gate chain in order — the first gate that fails explains the symptom: + +| Symptom | Gate to check | +|---|---| +| Raw `]133;…` / `]633;…` text on screen | The terminal does not render marks. Set `ShellIntegrationMode.Never`, or `NO_COLOR=1` as an end-user escape hatch. | +| No marks at all (expected some) | `ShellIntegration` mode (`Never`?); then `UseTerminalIntegration` actually called?; then the ANSI gate (`NO_COLOR`, `TERM=dumb`, `AnsiMode.Never`, redirected output with no hosted session). | +| No marks under Auto specifically | Detection: locally `WT_SESSION`/`TERM_PROGRAM`; under tmux/screen Auto stays off; for a hosted client, the advertised `ShellIntegrationMarks` capability. | +| Command navigation works but wrong dialect | Backend selection: OSC 633 only when VS Code is detected (`TERM_PROGRAM=vscode` locally or a `vscode` hosted identity), else OSC 133. | +| Marks missing only around one command | That command is protocol passthrough (see above) — this is intended. | + ## See Also - [Interactive Loop](interactive-loop.md) — where the marks sit in the prompt cycle From cd06c8aeeeeff62e1920aa38b1ac0b7b17245df7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 20:54:30 -0400 Subject: [PATCH 15/32] fix: bound C1 escaping, resolve prefixes on the captured graph before help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 Codex findings (P2). - Escape (F1): IsForbiddenControl escaped every char >= 0x80 once the builder path opened, so a legitimate char >= 0xa0 (e.g. 'é') following an escapable char was wrongly escaped ('echo caf\xe9'). Bound the C1 clause to <= 0x9f. Red-first test: escapable char before 'é'. - Single snapshot (F3): ResolveUniquePrefixes re-fetched the active graph internally, so prefix expansion and the route match used two snapshots — reopening the TOCTOU the refactor closed. Add a graph-taking overload and resolve prefixes against the captured graph in ResolveCommittedInput. - Prefix-before-help (F2): resolve prefixes before deciding help so 'ser --help' scopes help to the resolved 'server', matching the non-interactive path; carry the PrefixResolutionResult so the ambiguous branch reuses it instead of re-resolving. Characterization test: a prefix-abbreviated passthrough route (ser->serve) is classified passthrough (no C/D) through the shared-graph path. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/CoreReplApp.Routing.cs | 3 ++ src/Repl.Core/Routing/RoutingEngine.cs | 9 ++++-- src/Repl.Core/Session/InteractiveSession.cs | 30 +++++++++++-------- .../Terminal/ShellIntegrationMarkEmitter.cs | 8 +++-- ...nteractiveSession_ShellIntegrationMarks.cs | 16 ++++++++++ .../Given_ShellIntegrationMarkEmitter.cs | 5 ++++ 6 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Routing.cs b/src/Repl.Core/CoreReplApp.Routing.cs index dbd62fb..4ec16c7 100644 --- a/src/Repl.Core/CoreReplApp.Routing.cs +++ b/src/Repl.Core/CoreReplApp.Routing.cs @@ -63,6 +63,9 @@ private string BuildBannerText() => internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens) => RoutingEng.ResolveUniquePrefixes(tokens); + internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens, ActiveRoutingGraph activeGraph) => + RoutingEng.ResolveUniquePrefixes(tokens, activeGraph); + internal RouteDefinition[] ResolveDiscoverableRoutes( IReadOnlyList routes, IReadOnlyList contexts, diff --git a/src/Repl.Core/Routing/RoutingEngine.cs b/src/Repl.Core/Routing/RoutingEngine.cs index ddd11c7..777732c 100644 --- a/src/Repl.Core/Routing/RoutingEngine.cs +++ b/src/Repl.Core/Routing/RoutingEngine.cs @@ -378,9 +378,14 @@ internal string BuildBannerText() : $"{header}{Environment.NewLine}{description}"; } - internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens) + internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens) => + ResolveUniquePrefixes(tokens, app.ResolveActiveRoutingGraph()); + + // Overload taking a caller-captured graph so a single committed input is resolved + // against one snapshot end-to-end (prefix expansion + route match), rather than + // re-fetching the active graph here and racing a concurrent routing invalidation. + internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens, ActiveRoutingGraph activeGraph) { - var activeGraph = app.ResolveActiveRoutingGraph(); if (tokens.Count == 0) { return new PrefixResolutionResult(tokens: []); diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 5ca2628..3ddd310 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -160,8 +160,9 @@ internal async ValueTask RunInteractiveSessionAsync( app.GlobalOptionsSnapshotInstance.Update(resolution.Options!.CustomGlobalNamedOptions); if (resolution.Kind == CommittedKind.Ambiguous) { - var prefixResolution = app.ResolveUniquePrefixes(resolution.Options.RemainingTokens); - var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(prefixResolution); + // Reuse the prefix result from the single resolution — do not re-resolve + // against a fresh graph. + var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(resolution.Prefix!); _ = await app.RenderOutputAsync(ambiguous, resolution.Options.OutputFormat, cancellationToken, isInteractive: true) .ConfigureAwait(false); return (AmbientCommandOutcome.Handled, 1); @@ -311,6 +312,7 @@ private readonly record struct CommittedResolution( CommittedKind Kind, GlobalInvocationOptions? Options, ActiveRoutingGraph? Graph, + PrefixResolutionResult? Prefix, RouteResolver.RouteResolutionResult? Routes) { public bool IsProtocolPassthrough => @@ -319,6 +321,7 @@ private readonly record struct CommittedResolution( /// /// Resolves the committed input once, against a single + /// snapshot — prefix expansion, help scoping, and the route match all use that one /// snapshot, so the passthrough classification and the eventual execution can never /// diverge. Ambient classification uses , the /// single authority also consulted by . @@ -329,27 +332,30 @@ private CommittedResolution ResolveCommittedInput(IReadOnlyList inputTok { // Ambient commands win over routes sharing the same token and produce // normal terminal output, never a protocol payload. - return new CommittedResolution(CommittedKind.Ambient, Options: null, Graph: null, Routes: null); + return new CommittedResolution(CommittedKind.Ambient, Options: null, Graph: null, Prefix: null, Routes: null); } var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); var globalOptions = GlobalOptionParser.Parse(invocationTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); - if (globalOptions.HelpRequested) - { - // `serve --help` renders help; the payload never starts. - return new CommittedResolution(CommittedKind.Help, globalOptions, Graph: null, Routes: null); - } - var graph = app.ResolveActiveRoutingGraph(); - var prefixResolution = app.ResolveUniquePrefixes(globalOptions.RemainingTokens); + + // Resolve prefixes against the captured graph BEFORE deciding help or matching, so + // an abbreviation (`ser` -> `server`) is expanded consistently and `ser --help` + // scopes help to the resolved command — matching the non-interactive path. + var prefixResolution = app.ResolveUniquePrefixes(globalOptions.RemainingTokens, graph); if (prefixResolution.IsAmbiguous) { - return new CommittedResolution(CommittedKind.Ambiguous, globalOptions, graph, Routes: null); + return new CommittedResolution(CommittedKind.Ambiguous, globalOptions, graph, prefixResolution, Routes: null); } var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + if (resolvedOptions.HelpRequested) + { + return new CommittedResolution(CommittedKind.Help, resolvedOptions, graph, prefixResolution, Routes: null); + } + var routes = app.ResolveWithDiagnostics(resolvedOptions.RemainingTokens, graph.Routes); - return new CommittedResolution(CommittedKind.Routed, resolvedOptions, graph, routes); + return new CommittedResolution(CommittedKind.Routed, resolvedOptions, graph, prefixResolution, routes); } /// diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index ca0db89..61be57f 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -175,9 +175,11 @@ internal static string EscapeCommandLine(string commandLine) } // Space + C0 controls, DEL, and C1 controls: all can terminate or forge the OSC - // string on terminals that decode 8-bit control codes. Only reached for chars <= 0x9f - // (nothing above is in the SearchValues set), so the upper bound is implicit. - private static bool IsForbiddenControl(char ch) => ch <= ' ' || ch == '\x7f' || ch >= '\x80'; + // string on terminals that decode 8-bit control codes. The C1 clause needs an explicit + // upper bound: this predicate runs on every char once the builder path opens, so + // without it a legitimate char >= 0xa0 (e.g. 'é') that follows an escapable char would + // be escaped too. + private static bool IsForbiddenControl(char ch) => ch <= ' ' || ch == '\x7f' || (ch >= '\x80' && ch <= '\x9f'); // Resolves enablement and backend for the cycle that is about to start. Cheap by // design: environment variables are only consulted when no hosted session is active. diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index a21d221..52bb723 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -282,6 +282,22 @@ public void When_AmbientCommandSharesTokenWithPassthroughRoute_Then_AmbientLifec TerminalMarks.Count(raw, "]133;D;0").Should().Be(2); } + [TestMethod] + [Description("A prefix-abbreviated protocol-passthrough route (ser -> serve) is classified as passthrough by the single committed-input resolution — prefix expansion and the route match share one graph snapshot — so no output marks wrap its payload.")] + public void When_PrefixAbbreviatedPassthroughRuns_Then_NoOutputMarksWrapThePayload() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ser\rexit\r"); + + raw.Should().Contain("protocol-payload"); + TerminalMarks.Count(raw, "]133;C").Should().Be(1, because: "only the exit cycle may open an output region"); + TerminalMarks.Count(raw, "]133;D").Should().Be(1, because: "no command-end mark may trail the abbreviated passthrough payload"); + } + [TestMethod] [Description("Requesting --help on a protocol-passthrough route only renders help, so the normal lifecycle applies: the help cycle gets output-start and a successful command-end mark.")] public void When_PassthroughRouteRequestsHelp_Then_LifecycleMarksApplyNormally() diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index 7298058..eab895e 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -344,6 +344,11 @@ public void When_CommandLineContainsBackslashSemicolonAndControlChars_Then_Osc63 .Should().Be(@"st\x9cosc\x9dcsi\x9b"); ShellIntegrationMarkEmitter.EscapeCommandLine("high" + (char)0xe9 + "accent-stays") .Should().Be("high" + (char)0xe9 + "accent-stays"); + // A char above 0x9f (e.g. é, U+00E9) that appears AFTER an escapable char must + // still be preserved: the escape predicate is only reached in the builder path, + // so it must have an upper bound at 0x9f, not escape everything >= 0x80. + ShellIntegrationMarkEmitter.EscapeCommandLine("echo caf" + (char)0xe9) + .Should().Be(@"echo\x20caf" + (char)0xe9); } [TestMethod] From ca132c4bf5a625005df1163c73383420e056c152 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 21:12:55 -0400 Subject: [PATCH 16/32] fix: close the shell-integration cycle when the line read fails Round-7 Codex finding (P2, F4): once the prompt marks A/B are written, an exception or cancellation from the read path (or the history append) unwinds the loop with no matching D, stranding an open command segment on the terminal. Wrap the loop iteration so any failure emits a best-effort aborted command-end (no exit code) via TryWriteCommandEndAsync before rethrowing; the emitter's phase guard makes it a no-op when the cycle was already closed. Iteration body extracted to RunPromptCycleAsync to keep the loop within analyzer limits. Red-first test: an empty key queue makes the first read throw after A/B; the fix emits an aborted D before the exception propagates. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 103 ++++++++++++------ ...nteractiveSession_ShellIntegrationMarks.cs | 18 +++ 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 3ddd310..9c9f2c8 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -44,49 +44,82 @@ internal async ValueTask RunInteractiveSessionAsync( while (true) { cancellationToken.ThrowIfCancellationRequested(); - var readResult = await ReadInteractiveInputAsync( - marks, - scopeTokens, - historyProvider, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (readResult.Escaped) + try { - await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); - await ReplSessionIO.Output.WriteLineAsync().ConfigureAwait(false); - continue; // Esc at bare prompt → fresh line. + var (exit, updatedHistory) = await RunPromptCycleAsync( + marks, scopeTokens, historyProvider, lastHistoryEntry, serviceProvider, cancelHandler, cancellationToken) + .ConfigureAwait(false); + lastHistoryEntry = updatedHistory; + if (exit) + { + return 0; + } } - - var line = readResult.Line; - if (line is null) + catch { - await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); - return 0; + // The prompt marks (A/B) may have opened a cycle before the read or the + // history append failed. Close it with an aborted command-end (no exit + // code) so the terminal keeps no unterminated segment, then propagate. + // No-op if the cycle was already closed (ExecuteCommittedInputAsync handles + // its own exceptions), since the emitter's phase guard drops a second D. + await TryWriteCommandEndAsync(marks, exitCode: null).ConfigureAwait(false); + throw; } + } + } - var inputTokens = TokenizeInteractiveInput(line); - if (inputTokens.Count == 0) - { - await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); - continue; - } + /// + /// Runs one prompt cycle: emits the prompt marks, reads a line, and dispatches it. + /// Returns whether the session should exit and the updated last-history entry. + /// + private async ValueTask<(bool Exit, string? LastHistoryEntry)> RunPromptCycleAsync( + ShellIntegrationMarkEmitter marks, + List scopeTokens, + IHistoryProvider? historyProvider, + string? lastHistoryEntry, + IServiceProvider serviceProvider, + CancelKeyHandler cancelHandler, + CancellationToken cancellationToken) + { + var readResult = await ReadInteractiveInputAsync( + marks, + scopeTokens, + historyProvider, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (readResult.Escaped) + { + await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); + await ReplSessionIO.Output.WriteLineAsync().ConfigureAwait(false); + return (false, lastHistoryEntry); // Esc at bare prompt → fresh line. + } - lastHistoryEntry = await TryAppendHistoryAsync( - historyProvider, - lastHistoryEntry, - line, - cancellationToken) - .ConfigureAwait(false); + var line = readResult.Line; + if (line is null) + { + await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); + return (true, lastHistoryEntry); + } - var outcome = await ExecuteCommittedInputAsync( - marks, line, inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) - .ConfigureAwait(false); - if (outcome == AmbientCommandOutcome.Exit) - { - return 0; - } + var inputTokens = TokenizeInteractiveInput(line); + if (inputTokens.Count == 0) + { + await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); + return (false, lastHistoryEntry); } + + lastHistoryEntry = await TryAppendHistoryAsync( + historyProvider, + lastHistoryEntry, + line, + cancellationToken) + .ConfigureAwait(false); + + var outcome = await ExecuteCommittedInputAsync( + marks, line, inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + .ConfigureAwait(false); + return (outcome == AmbientCommandOutcome.Exit, lastHistoryEntry); } private async ValueTask ReadInteractiveInputAsync( diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 52bb723..5655fe9 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -412,6 +412,24 @@ public void When_CommandEndMarkWriteFails_Then_OriginalExceptionSurfaces() } } + [TestMethod] + [Description("If the line read throws after the prompt marks are written (A/B), the loop closes the open cycle with an aborted command-end mark (no exit code) before the exception propagates, so the terminal keeps no unterminated command segment.")] + public void When_LineReadFailsAfterPromptMarks_Then_AbortedCommandEndIsEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + // An empty key queue makes ConsoleLineReader's first ReadKeyAsync throw, after + // WritePromptStart (A) and WriteInputStart (B) have already run for that cycle. + var raw = RunInteractiveSession(harness, sut, typedInput: string.Empty, swallowRunExceptions: true); + + raw.Should().Contain("]133;B", because: "the prompt cycle opened before the read failed"); + raw.Should().Contain("]133;D", because: "the failed read must still close the cycle"); + raw.Should().NotContain("]133;D;", because: "a read failure is an aborted cycle, not a failure exit code"); + } + private static ReplApp CreateMarkedApp(ShellIntegrationMode mode = ShellIntegrationMode.Always) { var sut = ReplApp.Create() From da1985726b38c05c39df767eb677209e7fcc9a2f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 22:50:44 -0400 Subject: [PATCH 17/32] fix: apply parsed globals before resolving the committed route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 Codex finding (P2): ResolveCommittedInput resolved the route before DispatchInteractiveCommandAsync ran GlobalOptionsSnapshotInstance .Update, so module-presence predicates that read IGlobalOptionsAccessor during ResolveActiveRoutingGraph saw stale/default globals. Move the Update ahead of route resolution (matching the non-interactive path, verified: the CLI path finds an env-gated command from "--env prod"). Note: a full interactive observable (a per-command global re-gating a module) is additionally bounded by the routing-graph cache, which is populated at banner time with default globals and does not key on per-invocation global values — a pre-existing concern orthogonal to this PR, flagged for a separate follow-up. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- src/Repl.Core/Session/InteractiveSession.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 9c9f2c8..ccfe8f3 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -190,13 +190,12 @@ internal async ValueTask RunInteractiveSessionAsync( return (ambientOutcome, ambientOutcome == AmbientCommandOutcome.HandledError ? 1 : 0); } - app.GlobalOptionsSnapshotInstance.Update(resolution.Options!.CustomGlobalNamedOptions); if (resolution.Kind == CommittedKind.Ambiguous) { - // Reuse the prefix result from the single resolution — do not re-resolve - // against a fresh graph. + // Globals were already applied in ResolveCommittedInput (before routing); + // reuse the prefix result from that single resolution — do not re-resolve. var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(resolution.Prefix!); - _ = await app.RenderOutputAsync(ambiguous, resolution.Options.OutputFormat, cancellationToken, isInteractive: true) + _ = await app.RenderOutputAsync(ambiguous, resolution.Options!.OutputFormat, cancellationToken, isInteractive: true) .ConfigureAwait(false); return (AmbientCommandOutcome.Handled, 1); } @@ -370,6 +369,12 @@ private CommittedResolution ResolveCommittedInput(IReadOnlyList inputTok var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); var globalOptions = GlobalOptionParser.Parse(invocationTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); + + // Apply the parsed globals to the snapshot BEFORE resolving routes: module-presence + // predicates read IGlobalOptionsAccessor during ResolveActiveRoutingGraph, so a + // per-command global (e.g. `secret --env prod`) must be visible to routing or a + // gated command looks missing / a passthrough route is misclassified. + app.GlobalOptionsSnapshotInstance.Update(globalOptions.CustomGlobalNamedOptions); var graph = app.ResolveActiveRoutingGraph(); // Resolve prefixes against the captured graph BEFORE deciding help or matching, so From b39b863579706269b3720837e34faed3f209f319 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 00:40:13 -0400 Subject: [PATCH 18/32] test: narrow CaptureInteractiveRun to the expected exception type Code-quality nit: the mask-prevention test helper caught the generic Exception; narrow it to InvalidOperationException (the ambient-failure type it asserts) so any other exception escapes as a genuine test failure instead of being swallowed. Return type narrowed accordingly. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .../Given_InteractiveSession_ShellIntegrationMarks.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 5655fe9..75df94a 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -385,7 +385,7 @@ public void When_CommandEndMarkWriteFails_Then_OriginalExceptionSurfaces() thrown!.Message.Should().Contain("history --limit must be a positive integer"); } - private static Exception? CaptureInteractiveRun(TextWriter writer, ReplApp sut, string typedInput) + private static InvalidOperationException? CaptureInteractiveRun(TextWriter writer, ReplApp sut, string typedInput) { var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); var previousReader = ReplSessionIO.KeyReader; @@ -401,8 +401,10 @@ public void When_CommandEndMarkWriteFails_Then_OriginalExceptionSurfaces() _ = sut.Run([]); return null; } - catch (Exception ex) + catch (InvalidOperationException ex) { + // Only the expected ambient-failure exception is captured for assertion; + // any other type escapes as a genuine test failure. return ex; } } From 4459be93d3e167d2a7f2fb3d130cfd1932966824 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 10:40:16 -0400 Subject: [PATCH 19/32] fix: replace identity-inferred capabilities on terminal re-identification PR-review thread: TerminalIdentity updates OR-ed inferred flags into the session metadata, so a hosted client re-identifying from Windows Terminal to dumb kept ShellIntegrationMarks and OSC 133 marks kept flowing to a terminal that no longer claims support. SessionMetadata now tracks IdentityInferredCapabilities - the subset of the effective flags earned only by identity inference. A new identity replaces that subset (WithTerminalIdentity); explicit grants (overrides, control messages, resize/ANSI reports, wholesale sets) demote bits out of the inferred subset (WithExplicitCapabilities/WithoutCapabilities) so a later downgrade cannot revoke them. Both identity write sites (ReplSessionIO setter and StreamedReplHost.UpdateTerminalIdentity) share the same helpers. Red-first regressions: emitter-level WT->dumb (marks stop on the next prompt cycle), metadata-level inference replacement + explicit-grant survival, and the hosted-transport identity path. --- src/Repl.Core/Session/ReplSessionIO.cs | 91 +++++++++++++------ src/Repl.Defaults/StreamedReplHost.cs | 37 ++++---- src/Repl.Tests/Given_ReplSessionIO.cs | 53 +++++++++++ .../Given_ShellIntegrationMarkEmitter.cs | 23 +++++ 4 files changed, 155 insertions(+), 49 deletions(-) create mode 100644 src/Repl.Tests/Given_ReplSessionIO.cs diff --git a/src/Repl.Core/Session/ReplSessionIO.cs b/src/Repl.Core/Session/ReplSessionIO.cs index d441c52..68b77b1 100644 --- a/src/Repl.Core/Session/ReplSessionIO.cs +++ b/src/Repl.Core/Session/ReplSessionIO.cs @@ -19,7 +19,46 @@ internal readonly record struct SessionMetadata( string? RemotePeer, string? TerminalIdentity, TerminalCapabilities TerminalCapabilities, - DateTimeOffset LastUpdatedUtc); + TerminalCapabilities IdentityInferredCapabilities, + DateTimeOffset LastUpdatedUtc) + { + /// + /// Applies a reported terminal identity. Capability bits earned only by the previous + /// identity inference are replaced by the new inference, while explicitly granted bits + /// (overrides, control messages, actual resize/ANSI reports) are preserved — so a + /// Windows Terminal → dumb downgrade revokes the inferred marks support instead of + /// keeping it for the rest of the session. + /// + public SessionMetadata WithTerminalIdentity(string? terminalIdentity) + { + var explicitCapabilities = TerminalCapabilities & ~IdentityInferredCapabilities; + var inferred = TerminalCapabilitiesClassifier.InferFromIdentity(terminalIdentity); + return this with + { + TerminalIdentity = terminalIdentity, + TerminalCapabilities = explicitCapabilities | inferred, + // Only bits this identity actually earned are attributed to inference; bits + // already granted explicitly stay revocation-proof on the next identity change. + IdentityInferredCapabilities = inferred & ~explicitCapabilities, + }; + } + + /// Grants capability bits explicitly, so a later identity change cannot revoke them. + public SessionMetadata WithExplicitCapabilities(TerminalCapabilities granted) => + this with + { + TerminalCapabilities = TerminalCapabilities | granted, + IdentityInferredCapabilities = IdentityInferredCapabilities & ~granted, + }; + + /// Revokes capability bits explicitly (for example an ANSI opt-out). + public SessionMetadata WithoutCapabilities(TerminalCapabilities revoked) => + this with + { + TerminalCapabilities = TerminalCapabilities & ~revoked, + IdentityInferredCapabilities = IdentityInferredCapabilities & ~revoked, + }; + } private static readonly AsyncLocal s_output = new(); private static readonly AsyncLocal s_error = new(); @@ -100,13 +139,13 @@ public static bool? AnsiSupport sessionId, session => { - var capabilities = value switch + var updated = value switch { - true => session.TerminalCapabilities | TerminalCapabilities.Ansi, - false => session.TerminalCapabilities & ~TerminalCapabilities.Ansi, - _ => session.TerminalCapabilities, + true => session.WithExplicitCapabilities(TerminalCapabilities.Ansi), + false => session.WithoutCapabilities(TerminalCapabilities.Ansi), + _ => session, }; - return session with { AnsiSupport = value, TerminalCapabilities = capabilities }; + return updated with { AnsiSupport = value }; }); } } @@ -126,10 +165,10 @@ public static (int Width, int Height)? WindowSize sessionId, session => { - var capabilities = value is { } - ? session.TerminalCapabilities | TerminalCapabilities.ResizeReporting - : session.TerminalCapabilities; - return session with { WindowSize = value, TerminalCapabilities = capabilities }; + var updated = value is { } + ? session.WithExplicitCapabilities(TerminalCapabilities.ResizeReporting) + : session; + return updated with { WindowSize = value }; }); } } @@ -184,14 +223,7 @@ public static string? TerminalIdentity { if (TryGetCurrentSessionId(out var sessionId)) { - UpdateSession( - sessionId, - session => - { - var inferred = TerminalCapabilitiesClassifier.InferFromIdentity(value); - var capabilities = session.TerminalCapabilities | inferred; - return session with { TerminalIdentity = value, TerminalCapabilities = capabilities }; - }); + UpdateSession(sessionId, session => session.WithTerminalIdentity(value)); } } } @@ -206,7 +238,15 @@ public static TerminalCapabilities TerminalCapabilities { if (TryGetCurrentSessionId(out var sessionId)) { - UpdateSession(sessionId, session => session with { TerminalCapabilities = value }); + UpdateSession( + sessionId, + session => session with + { + TerminalCapabilities = value, + // A wholesale set is authoritative: nothing remains attributable to + // identity inference until the next identity report. + IdentityInferredCapabilities = TerminalCapabilities.None, + }); } } } @@ -253,21 +293,13 @@ public static IDisposable SetSession( { UpdateSession( resolvedSessionId, - session => session with - { - AnsiSupport = true, - TerminalCapabilities = session.TerminalCapabilities | TerminalCapabilities.Ansi, - }); + session => session.WithExplicitCapabilities(TerminalCapabilities.Ansi) with { AnsiSupport = true }); } else if (ansiMode == AnsiMode.Never) { UpdateSession( resolvedSessionId, - session => session with - { - AnsiSupport = false, - TerminalCapabilities = session.TerminalCapabilities & ~TerminalCapabilities.Ansi, - }); + session => session.WithoutCapabilities(TerminalCapabilities.Ansi) with { AnsiSupport = false }); } return new SessionScope( @@ -347,6 +379,7 @@ private static SessionMetadata CreateMetadata(string sessionId) => RemotePeer: null, TerminalIdentity: null, TerminalCapabilities: TerminalCapabilities.None, + IdentityInferredCapabilities: TerminalCapabilities.None, LastUpdatedUtc: DateTimeOffset.UtcNow); private static SessionMetadata NormalizeSession(string sessionId, SessionMetadata session) => diff --git a/src/Repl.Defaults/StreamedReplHost.cs b/src/Repl.Defaults/StreamedReplHost.cs index 5acbe6d..b363a3e 100644 --- a/src/Repl.Defaults/StreamedReplHost.cs +++ b/src/Repl.Defaults/StreamedReplHost.cs @@ -90,11 +90,8 @@ public void UpdateWindowSize(int width, int height) ReplSessionIO.UpdateSession( SessionId, - session => session with - { - WindowSize = (width, height), - TerminalCapabilities = session.TerminalCapabilities | TerminalCapabilities.ResizeReporting, - }); + session => session.WithExplicitCapabilities(TerminalCapabilities.ResizeReporting) + with { WindowSize = (width, height), }); } /// @@ -107,17 +104,11 @@ public void UpdateTerminalIdentity(string? terminalIdentity) return; } + // Same replace-inferred semantics as ReplSessionIO.TerminalIdentity: a downgrade + // (e.g. Windows Terminal → dumb) revokes the previously inferred capabilities. ReplSessionIO.UpdateSession( SessionId, - session => - { - var inferred = TerminalCapabilitiesClassifier.InferFromIdentity(terminalIdentity); - return session with - { - TerminalIdentity = terminalIdentity, - TerminalCapabilities = session.TerminalCapabilities | inferred, - }; - }); + session => session.WithTerminalIdentity(terminalIdentity)); } /// @@ -134,10 +125,10 @@ public void UpdateAnsiSupport(bool? ansiSupported) SessionId, session => { - var capabilities = ansiSupported.Value - ? session.TerminalCapabilities | TerminalCapabilities.Ansi - : session.TerminalCapabilities & ~TerminalCapabilities.Ansi; - return session with { AnsiSupport = ansiSupported.Value, TerminalCapabilities = capabilities }; + var updated = ansiSupported.Value + ? session.WithExplicitCapabilities(TerminalCapabilities.Ansi) + : session.WithoutCapabilities(TerminalCapabilities.Ansi); + return updated with { AnsiSupport = ansiSupported.Value }; }); } @@ -148,7 +139,7 @@ public void UpdateTerminalCapabilities(TerminalCapabilities capabilities) { ReplSessionIO.UpdateSession( SessionId, - session => session with { TerminalCapabilities = session.TerminalCapabilities | capabilities }); + session => session.WithExplicitCapabilities(capabilities)); } /// @@ -306,7 +297,13 @@ private void ApplyConfiguredMetadata(ReplRunOptions runOptions) { ReplSessionIO.UpdateSession( SessionId, - session => session with { TerminalCapabilities = forcedCapabilities }); + session => session with + { + TerminalCapabilities = forcedCapabilities, + // Forced overrides are authoritative: nothing remains attributable to + // identity inference until the next identity report. + IdentityInferredCapabilities = TerminalCapabilities.None, + }); } } diff --git a/src/Repl.Tests/Given_ReplSessionIO.cs b/src/Repl.Tests/Given_ReplSessionIO.cs new file mode 100644 index 0000000..2b0a0da --- /dev/null +++ b/src/Repl.Tests/Given_ReplSessionIO.cs @@ -0,0 +1,53 @@ +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ReplSessionIO +{ + [TestMethod] + [Description("Capability bits earned only by identity inference are replaced when the client re-identifies: a Windows Terminal → dumb downgrade clears ShellIntegrationMarks (and the other inferred flags) instead of keeping them forever.")] + public void When_TerminalIdentityDowngrades_Then_InferredCapabilitiesAreReplaced() + { + using var session = ReplSessionIO.SetSession(new StringWriter(), TextReader.Null); + + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + var advertised = ReplSessionIO.TerminalCapabilities; + ReplSessionIO.TerminalIdentity = "dumb"; + var downgraded = ReplSessionIO.TerminalCapabilities; + + advertised.Should().HaveFlag(TerminalCapabilities.ShellIntegrationMarks); + downgraded.Should().NotHaveFlag(TerminalCapabilities.ShellIntegrationMarks); + downgraded.Should().NotHaveFlag(TerminalCapabilities.Ansi); + downgraded.Should().HaveFlag(TerminalCapabilities.IdentityReporting); + } + + [TestMethod] + [Description("Capabilities granted explicitly (overrides, control messages) are not revoked by a later identity downgrade — only the portion the previous identity inference earned is recalculated.")] + public void When_TerminalIdentityDowngrades_Then_ExplicitlyGrantedCapabilitiesSurvive() + { + using var session = ReplSessionIO.SetSession(new StringWriter(), TextReader.Null); + + ReplSessionIO.TerminalCapabilities = TerminalCapabilities.VtInput; + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + ReplSessionIO.TerminalIdentity = "dumb"; + + ReplSessionIO.TerminalCapabilities.Should().HaveFlag( + TerminalCapabilities.VtInput, + because: "the explicit grant predates the inference and must survive the downgrade"); + ReplSessionIO.TerminalCapabilities.Should().NotHaveFlag(TerminalCapabilities.ShellIntegrationMarks); + } + + [TestMethod] + [Description("A hosted transport's identity update (Telnet TTYPE / control-message path) follows the same replace-inferred rule as the ambient setter, so the downgrade behavior covers both write sites.")] + public async Task When_HostIdentityDowngrades_Then_InferredCapabilitiesAreReplaced() + { + await using var host = new StreamedReplHost(new StringWriter()); + + host.UpdateTerminalIdentity("Windows Terminal"); + host.UpdateTerminalIdentity("dumb"); + + ReplSessionIO.TryGetSession(host.SessionId, out var session).Should().BeTrue(); + session.TerminalCapabilities.Should().NotHaveFlag(TerminalCapabilities.ShellIntegrationMarks); + session.TerminalIdentity.Should().Be("dumb"); + } +} diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index eab895e..5cf0b20 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -392,6 +392,29 @@ public async Task When_HostedSessionAdvertisesMarksMidSession_Then_MarksAppearOn harness.RawOutput.Should().Contain("]133;D;0"); } + [TestMethod] + [Description("Auto mode honors a mid-session identity downgrade: capability bits earned only by a previous identity inference are dropped when the client re-identifies as a markless terminal, so marks stop on the next prompt cycle instead of flowing forever.")] + public async Task When_HostedClientDowngradesToDumbMidSession_Then_MarksStopOnNextPromptCycle() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + await RunFullLifecycleAsync(emitter); + harness.RawOutput.Should().Contain("]133;A", because: "sanity: marks flow while the client identifies as Windows Terminal"); + + ReplSessionIO.TerminalIdentity = "dumb"; + var markCountBeforeSecondCycle = TerminalMarks.Count(harness.RawOutput, "]133;"); + await RunFullLifecycleAsync(emitter); + + TerminalMarks.Count(harness.RawOutput, "]133;") + .Should().Be(markCountBeforeSecondCycle, because: "the latest identity no longer advertises shell-integration marks"); + } + [TestMethod] [Description("An aborted or empty command reports D without an exit-code parameter, matching the FinalTerm 'command aborted' form.")] public async Task When_CommandEndsWithoutExitCode_Then_DIsEmittedWithoutParameter() From cc9a05872f192b6ac1f649c6a68c0722fe7fede7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 10:53:26 -0400 Subject: [PATCH 20/32] fix: run interactive passthrough dispatch under the shared protocol contract PR-review thread: the interactive loop used its passthrough classification only to suppress shell-integration marks, then dispatched through ExecuteMatchedCommandAsync directly - bypassing PushProtocolPassthrough and the hosted-capability guard. A handler probing ReplSessionIO.IsProtocolPassthrough saw false interactively but true in CLI one-shot for the same route, and everything gated on that flag (advanced progress, styling) could interleave with a protocol payload. ExecuteProtocolPassthroughCommandAsync is now the single execution contract: it owns the hosted-not-supported guard (moved from the CLI call site, unchanged behavior there) plus the passthrough scope and stream isolation, and the interactive Routed dispatch routes passthrough matches through it. Unmatched-input handling extracted to keep the method within analyzer limits. Red-first regressions: an interactive handler observes IsProtocolPassthrough == true, and a hosted interactive session rejects a passthrough route lacking an IReplIoContext parameter with the same error as CLI. Existing interactive passthrough tests now declare hosted-capable handlers (IReplIoContext parameter), preserving their streamed-payload intent under the guard. --- src/Repl.Core/CoreReplApp.Execution.cs | 35 +++++++----- src/Repl.Core/Session/InteractiveSession.cs | 26 +++++++++ ...nteractiveSession_ShellIntegrationMarks.cs | 56 +++++++++++++++++-- 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 9d5ba98..c80ea8a 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -217,20 +217,6 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken) { - if (match.Route.Command.IsProtocolPassthrough - && ReplSessionIO.IsHostedSession - && !match.Route.Command.SupportsHostedProtocolPassthrough) - { - _ = await RenderOutputAsync( - Results.Error( - "protocol_passthrough_hosted_not_supported", - $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."), - globalOptions.OutputFormat, - cancellationToken) - .ConfigureAwait(false); - return 1; - } - if (match.Route.Command.IsProtocolPassthrough) { return await ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) @@ -256,12 +242,31 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( return exitCode; } - private async ValueTask ExecuteProtocolPassthroughCommandAsync( + /// + /// Executes a protocol-passthrough command under the single passthrough contract shared + /// by the CLI one-shot and interactive paths: the hosted-capability guard, the + /// scope handlers can observe, and — + /// outside hosted sessions — stdout/stderr isolation (framework output on stderr, the + /// handler payload alone on stdout). + /// + internal async ValueTask ExecuteProtocolPassthroughCommandAsync( RouteMatch match, GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, CancellationToken cancellationToken) { + if (ReplSessionIO.IsHostedSession && !match.Route.Command.SupportsHostedProtocolPassthrough) + { + _ = await RenderOutputAsync( + Results.Error( + "protocol_passthrough_hosted_not_supported", + $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } + using var protocolPassthroughScope = ReplSessionIO.PushProtocolPassthrough(); if (ReplSessionIO.IsSessionActive) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index ccfe8f3..b32a385 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -447,10 +447,36 @@ private async ValueTask ExecuteInteractiveInputAsync( var match = resolution.Match; if (match is not null) { + if (match.Route.Command.IsProtocolPassthrough) + { + // Same execution contract as the CLI one-shot path — hosted-capability guard, + // protocol-passthrough scope, and stream isolation — so a handler probing + // IsProtocolPassthrough observes the same value in both modes. + return await app.ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) + .ConfigureAwait(false); + } + var (exitCode, _) = await app.ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); return exitCode; } + return await HandleUnmatchedInteractiveInputAsync( + activeGraph, resolution, globalOptions, scopeTokens, serviceProvider, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Handles a committed input that matched no route: context navigation when the tokens + /// name a context, a route-resolution failure otherwise. + /// + private async ValueTask HandleUnmatchedInteractiveInputAsync( + ActiveRoutingGraph activeGraph, + RouteResolver.RouteResolutionResult resolution, + GlobalInvocationOptions globalOptions, + List scopeTokens, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, app.OptionsSnapshot.Parsing); if (contextMatch is not null) { diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 75df94a..46555fe 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -206,7 +206,7 @@ public void When_PassthroughCommandHasGlobalOption_Then_NoOutputMarksWrapThePayl { using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(); - sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); var raw = RunInteractiveSession(harness, sut, "serve --json\rexit\r"); @@ -221,7 +221,7 @@ public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksW { using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(); - sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); @@ -241,7 +241,7 @@ public void When_PassthroughCommandFailsValidation_Then_CycleIsAbandonedWithoutM { using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(); - sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); var raw = RunInteractiveSession(harness, sut, "serve --bogus 42\rexit\r"); @@ -257,7 +257,7 @@ public void When_PassthroughHandlerExitsNonZero_Then_NoMarkTrailsThePayload() { using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(); - sut.Map("serve", () => Results.Exit(7)).AsProtocolPassthrough(); + sut.Map("serve", (IReplIoContext _) => Results.Exit(7)).AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); @@ -266,6 +266,52 @@ public void When_PassthroughHandlerExitsNonZero_Then_NoMarkTrailsThePayload() TerminalMarks.Count(raw, "]133;D").Should().Be(1, because: "only the exit cycle may report a command end"); } + [TestMethod] + [Description("A protocol-passthrough route dispatched from the interactive loop runs under the same passthrough contract as CLI one-shot execution: the handler observes an active protocol-passthrough scope, so the stdout/stderr/session isolation contract holds in both modes.")] + public void When_PassthroughCommandRunsInteractively_Then_HandlerObservesPassthroughScope() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + bool? observedPassthrough = null; + sut.Map( + "serve", + (IReplIoContext _) => + { + observedPassthrough = ReplSessionIO.IsProtocolPassthrough; + return "protocol-payload"; + }) + .AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + _ = RunInteractiveSession(harness, sut, "serve\rexit\r"); + + observedPassthrough.Should().BeTrue( + because: "interactive dispatch must honor the same protocol-passthrough contract as the CLI one-shot path"); + } + + [TestMethod] + [Description("In a hosted interactive session, a protocol-passthrough route whose handler cannot run hosted (no IReplIoContext parameter) is rejected with the same error as the CLI one-shot path, instead of silently running without the isolation contract.")] + public void When_HostedInteractivePassthroughLacksIoContext_Then_HostedGuardRejectsLikeCli() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + var handlerRan = false; + sut.Map( + "serve", + () => + { + handlerRan = true; + return "protocol-payload"; + }) + .AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve\rexit\r"); + + handlerRan.Should().BeFalse(because: "the hosted guard must reject before dispatch, matching CLI one-shot behavior"); + raw.Should().Contain("requires a handler parameter of type IReplIoContext"); + } + [TestMethod] [Description("An ambient command that shares its token with a protocol-passthrough route is handled ambient-first and keeps the normal lifecycle: output-start and a command-end mark wrap its terminal output.")] public void When_AmbientCommandSharesTokenWithPassthroughRoute_Then_AmbientLifecycleApplies() @@ -288,7 +334,7 @@ public void When_PrefixAbbreviatedPassthroughRuns_Then_NoOutputMarksWrapThePaylo { using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); var sut = CreateMarkedApp(); - sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); + sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); var raw = RunInteractiveSession(harness, sut, "ser\rexit\r"); From 1668bd85769ecf71421ee08af0a2d2fb482d3fc1 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 11:31:31 -0400 Subject: [PATCH 21/32] test: prove the interactive globals-before-route order with a cache-bypass red Review finding: da19857 (apply parsed globals before resolving the committed route) landed without an executable red because the routing graph cached at banner time masks the interactive observable. The regression drives the cache-bypass path: a module gated on a per-command global (--env prod) plus a command calling InvalidateRouting, so the next committed line is the first resolution at the new cache version and the presence predicate reads the just-applied global. Autocomplete is off so nothing re-caches a stale graph between the two. Red was executed and observed by temporarily moving the snapshot Update back after route resolution (module absent, route failure); the committed order keeps it green. StaticWindowSizeProvider promoted from a private lifecycle-test helper to a shared IntegrationTests helper - the streamed host needs it to skip DTTERM VT probing, which never answers in tests. --- .../Given_GlobalOptionsAccessor.cs | 41 +++++++++++++++++++ .../Given_TerminalMetadataLifecycle.cs | 12 ------ .../StaticWindowSizeProvider.cs | 19 +++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 src/Repl.IntegrationTests/StaticWindowSizeProvider.cs diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 58f70f9..05cca86 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -420,6 +420,47 @@ await sut.Core.RunSubInvocationAsync( captured.Should().AllBe("acme"); } + [TestMethod] + [Description("Regression for the interactive committed-input order: parsed globals are applied BEFORE route resolution, so once the routing cache is invalidated, a module gated on a per-command global (--env prod) is present for that same command line. Red was observed with the Update call moved after route resolution.")] + public async Task When_GlobalGatedModuleResolvesInteractively_Then_PerCommandGlobalIsVisibleToPresencePredicate() + { + var output = new StringWriter(); + var sut = ReplApp.Create(); + // Autocomplete resolves the routing graph per keystroke; keep it out of the way so + // the committed-input resolution is the first one after the invalidation below. + sut.Options(options => + { + options.Interactive.Autocomplete.Mode = AutocompleteMode.Off; + options.Parsing.AddGlobalOption("env"); + }); + sut.MapModule( + new EnvGatedModule(), + context => string.Equals( + (context.ServiceProvider.GetService(typeof(IGlobalOptionsAccessor)) as IGlobalOptionsAccessor) + ?.GetValue("env"), + "prod", + StringComparison.Ordinal)); + sut.Map("reload", () => + { + sut.Core.InvalidateRouting(); + return "reloaded"; + }); + var host = new StreamedReplHost(output, new StaticWindowSizeProvider()); + + host.EnqueueInput($"reload{Environment.NewLine}secret --env prod{Environment.NewLine}exit{Environment.NewLine}"); + var exitCode = await host.RunSessionAsync(sut, new ReplRunOptions()); + + exitCode.Should().Be(0); + output.ToString().Should().Contain( + "classified-42", + because: "the per-command global must be applied before the re-evaluated routing graph gates the module"); + } + + private sealed class EnvGatedModule : IReplModule + { + public void Map(IReplMap map) => map.Map("secret", () => "classified-42"); + } + private sealed class DecimalGlobalOptions { public double Rate { get; set; } diff --git a/src/Repl.IntegrationTests/Given_TerminalMetadataLifecycle.cs b/src/Repl.IntegrationTests/Given_TerminalMetadataLifecycle.cs index ff4ed7c..91daa58 100644 --- a/src/Repl.IntegrationTests/Given_TerminalMetadataLifecycle.cs +++ b/src/Repl.IntegrationTests/Given_TerminalMetadataLifecycle.cs @@ -127,16 +127,4 @@ private static ReplApp CreateSut() return ReplApp.Create().UseDefaultInteractive(); } - private sealed class StaticWindowSizeProvider((int Width, int Height)? initialSize = null) : IWindowSizeProvider - { - private readonly (int Width, int Height)? _initialSize = initialSize; - - public event EventHandler? SizeChanged; - - public ValueTask<(int Width, int Height)?> GetSizeAsync(CancellationToken cancellationToken) => - ValueTask.FromResult(_initialSize); - - public void Push(int width, int height) => - SizeChanged?.Invoke(this, new WindowSizeEventArgs(width, height)); - } } diff --git a/src/Repl.IntegrationTests/StaticWindowSizeProvider.cs b/src/Repl.IntegrationTests/StaticWindowSizeProvider.cs new file mode 100644 index 0000000..bc32aa6 --- /dev/null +++ b/src/Repl.IntegrationTests/StaticWindowSizeProvider.cs @@ -0,0 +1,19 @@ +namespace Repl.IntegrationTests; + +/// +/// Deterministic for tests: reports a fixed initial size +/// (no DTTERM VT probing, which would block on a terminal that never answers) and lets the +/// test push resize events manually. +/// +internal sealed class StaticWindowSizeProvider((int Width, int Height)? initialSize = null) : IWindowSizeProvider +{ + private readonly (int Width, int Height)? _initialSize = initialSize; + + public event EventHandler? SizeChanged; + + public ValueTask<(int Width, int Height)?> GetSizeAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(_initialSize); + + public void Push(int width, int height) => + SizeChanged?.Invoke(this, new WindowSizeEventArgs(width, height)); +} From f3cf43008a95b2b75e16ad5acb10f555b04e5e78 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 11:42:03 -0400 Subject: [PATCH 22/32] feat: name the deciding shell-integration gate for exact triage Review finding (operability): ResolveEnabled folded 7+ inputs into a bool with no way to tell which gate decided, leaving black-box symptom guessing as the only triage path. The resolution now returns ShellIntegrationGate - an internal enum naming the deciding gate in its fixed evaluation order - recorded per cycle as ShellIntegrationMarkEmitter.LastGate. Deliberately internal and knob-free: no new public surface, no env vars; tests pin the tricky decisions (passthrough, hosted not-advertising vs enabled, not-configured vs mode-never) and the troubleshooting doc now states the exact gate order instead of calling the decision a black box. Red-first: the gate tests were written against the missing member. --- docs/terminal-shell-integration.md | 2 +- .../Terminal/ShellIntegrationGate.cs | 34 +++++++++++ .../Terminal/ShellIntegrationMarkEmitter.cs | 51 ++++++++++++---- .../Given_ShellIntegrationMarkEmitter.cs | 60 +++++++++++++++++++ 4 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 src/Repl.Core/Terminal/ShellIntegrationGate.cs diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index bc3d885..2ffdb1a 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -72,7 +72,7 @@ Hosted sessions (WebSocket, Telnet) receive marks when their reported terminal i ## Troubleshooting -The enablement decision is a black box at runtime (it emits no diagnostics). When marks misbehave, walk the gate chain in order — the first gate that fails explains the symptom: +The enablement decision emits no runtime diagnostics, but it is deterministic: the gates are evaluated in a fixed order and the first failing gate decides. In order: integration configured → not in protocol passthrough → ANSI capable → output not redirected (local only) → mode (`Always`/`Never`) → capability advertised (hosted) or terminal recognized (local). When marks misbehave, walk that chain — the first gate that fails explains the symptom: | Symptom | Gate to check | |---|---| diff --git a/src/Repl.Core/Terminal/ShellIntegrationGate.cs b/src/Repl.Core/Terminal/ShellIntegrationGate.cs new file mode 100644 index 0000000..5b8b618 --- /dev/null +++ b/src/Repl.Core/Terminal/ShellIntegrationGate.cs @@ -0,0 +1,34 @@ +namespace Repl; + +/// +/// Names the gate that decided shell-integration mark enablement for a prompt cycle. +/// The members after are listed in evaluation order — the first +/// failing gate wins — so a wrong on/off decision can be triaged exactly. Mirrors the +/// troubleshooting table in docs/terminal-shell-integration.md. +/// +internal enum ShellIntegrationGate +{ + /// All gates passed: marks are emitted this cycle. + Enabled, + + /// The app never called UseTerminalIntegration. + NotConfigured, + + /// A protocol-passthrough scope is active: no mark may reach a protocol stream. + ProtocolPassthrough, + + /// Neither the writer nor the session capabilities support ANSI escape sequences. + AnsiUnsupported, + + /// Local console output is redirected (no terminal on the other end). + OutputRedirected, + + /// was configured. + ModeNever, + + /// Auto mode in a hosted session whose client has not advertised mark support. + SessionNotAdvertising, + + /// Auto mode on a local terminal the environment classifier does not recognize. + EnvironmentUnknown, +} diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 61be57f..0f36374 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -181,25 +181,46 @@ internal static string EscapeCommandLine(string commandLine) // be escaped too. private static bool IsForbiddenControl(char ch) => ch <= ' ' || ch == '\x7f' || (ch >= '\x80' && ch <= '\x9f'); + /// + /// The gate that decided enablement for the current prompt cycle. Diagnostic-only: + /// lets tests (and debugging sessions) see WHY marks are on or off instead of + /// reverse-engineering the decision from the emitted bytes. + /// + internal ShellIntegrationGate LastGate { get; private set; } = ShellIntegrationGate.NotConfigured; + // Resolves enablement and backend for the cycle that is about to start. Cheap by // design: environment variables are only consulted when no hosted session is active. private void RefreshCycleConfiguration() { - _enabled = ResolveEnabled(_options, _outputOptions); + LastGate = ResolveGate(_options, _outputOptions); + _enabled = LastGate == ShellIntegrationGate.Enabled; _isVsCodeBackend = _enabled && IsVsCodeBackend(); _marks = _isVsCodeBackend ? Osc633 : Osc133; } - private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOptions outputOptions) + // Gates are evaluated in ShellIntegrationGate member order; the first failing gate + // names the decision. The structural gates mirror advanced progress (OSC 9;4): marks + // must never reach protocol streams, non-ANSI writers, or redirected local output. + private static ShellIntegrationGate ResolveGate(TerminalIntegrationOptions? options, OutputOptions outputOptions) { - // Same structural gates as advanced progress (OSC 9;4): marks must never reach - // protocol streams, non-ANSI writers, or redirected local output. - if (options is null - || ReplSessionIO.IsProtocolPassthrough - || !IsAnsiCapableForMarks(outputOptions) - || (Console.IsOutputRedirected && !ReplSessionIO.IsSessionActive)) + if (options is null) + { + return ShellIntegrationGate.NotConfigured; + } + + if (ReplSessionIO.IsProtocolPassthrough) + { + return ShellIntegrationGate.ProtocolPassthrough; + } + + if (!IsAnsiCapableForMarks(outputOptions)) + { + return ShellIntegrationGate.AnsiUnsupported; + } + + if (Console.IsOutputRedirected && !ReplSessionIO.IsSessionActive) { - return false; + return ShellIntegrationGate.OutputRedirected; } // Hosted sessions decide from what the remote client advertised, never from the @@ -207,10 +228,14 @@ private static bool ResolveEnabled(TerminalIntegrationOptions? options, OutputOp // server runs in, not the WebSocket/Telnet client on the other end. return options.ShellIntegration switch { - ShellIntegrationMode.Always => true, - ShellIntegrationMode.Never => false, - _ when ReplSessionIO.IsSessionActive => SessionAdvertisesShellIntegration(), - _ => TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal(), + ShellIntegrationMode.Always => ShellIntegrationGate.Enabled, + ShellIntegrationMode.Never => ShellIntegrationGate.ModeNever, + _ when ReplSessionIO.IsSessionActive => SessionAdvertisesShellIntegration() + ? ShellIntegrationGate.Enabled + : ShellIntegrationGate.SessionNotAdvertising, + _ => TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal() + ? ShellIntegrationGate.Enabled + : ShellIntegrationGate.EnvironmentUnknown, }; } diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index 5cf0b20..8be8e10 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -479,6 +479,66 @@ public async Task When_GenericBackendIsActive_Then_CommandLineMarkIsSuppressed() harness.RawOutput.Should().NotContain(";E;"); } + [TestMethod] + [Description("Each prompt cycle records which gate decided the enablement, in the documented order, so a wrong on/off decision is triaged exactly instead of by symptom guessing. This pins the hosted Auto path: not advertised → advertised.")] + public async Task When_HostedAutoResolves_Then_DecidingGateIsRecorded() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Auto); + + await emitter.WritePromptStartAsync(); + var beforeAdvertising = emitter.LastGate; + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + await emitter.WritePromptStartAsync(); + var afterAdvertising = emitter.LastGate; + + beforeAdvertising.Should().Be(ShellIntegrationGate.SessionNotAdvertising); + afterAdvertising.Should().Be(ShellIntegrationGate.Enabled); + } + + [TestMethod] + [Description("The protocol-passthrough gate is recorded as the deciding reason when a passthrough scope is active, ahead of any mode or capability consideration.")] + public async Task When_ProtocolPassthroughIsActive_Then_GateRecordsIt() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var emitter = CreateEmitter(ShellIntegrationMode.Always); + + using var passthrough = ReplSessionIO.PushProtocolPassthrough(); + await emitter.WritePromptStartAsync(); + + emitter.LastGate.Should().Be(ShellIntegrationGate.ProtocolPassthrough); + } + + [TestMethod] + [Description("An app that never called UseTerminalIntegration records the not-configured gate, and Never mode records the mode gate — the two 'off by design' reasons stay distinguishable.")] + public async Task When_IntegrationIsOffByDesign_Then_GateDistinguishesWhy() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + var notConfigured = ShellIntegrationMarkEmitter.Create(options: null, new OutputOptions { AnsiMode = AnsiMode.Always }); + var neverMode = CreateEmitter(ShellIntegrationMode.Never); + + await notConfigured.WritePromptStartAsync(); + await neverMode.WritePromptStartAsync(); + + notConfigured.LastGate.Should().Be(ShellIntegrationGate.NotConfigured); + neverMode.LastGate.Should().Be(ShellIntegrationGate.ModeNever); + } + private static ShellIntegrationMarkEmitter CreateEmitter(ShellIntegrationMode mode) => ShellIntegrationMarkEmitter.Create( new TerminalIntegrationOptions { ShellIntegration = mode }, From 81bd7444e82ec733505bf43eb730105fed08aa09 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 11:46:35 -0400 Subject: [PATCH 23/32] refactor: make CommittedResolution kind/field pairing structural Review finding (architect/quality/style): the resolution record was a union-by-nullable-fields consumed through five null-forgiving operators in production code, so a new kind or field drift would compile silently and NRE at runtime. Per-kind factories (Ambient/Ambiguous/Help/Routed) now own which fields are populated, and guarded accessors throw a descriptive InvalidOperationException on a kind mismatch instead of relying on `!`. No behavior change; existing interactive-loop tests cover all four kinds. --- src/Repl.Core/Session/InteractiveSession.cs | 84 +++++++++++++++++---- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index b32a385..4b7b3bb 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -194,8 +194,8 @@ internal async ValueTask RunInteractiveSessionAsync( { // Globals were already applied in ResolveCommittedInput (before routing); // reuse the prefix result from that single resolution — do not re-resolve. - var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(resolution.Prefix!); - _ = await app.RenderOutputAsync(ambiguous, resolution.Options!.OutputFormat, cancellationToken, isInteractive: true) + var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(resolution.Prefix); + _ = await app.RenderOutputAsync(ambiguous, resolution.Options.OutputFormat, cancellationToken, isInteractive: true) .ConfigureAwait(false); return (AmbientCommandOutcome.Handled, 1); } @@ -339,16 +339,68 @@ private enum CommittedKind /// The single resolution of one committed input line: whether it is an ambient /// command, a help request, an ambiguous prefix, or a resolved route — captured /// against one routing-graph snapshot and reused by both the mark decision and dispatch. + /// Built through the per-kind factories so which fields are populated is structural: + /// the guarded accessors throw on a kind mismatch instead of null-forgiving reads. /// - private readonly record struct CommittedResolution( - CommittedKind Kind, - GlobalInvocationOptions? Options, - ActiveRoutingGraph? Graph, - PrefixResolutionResult? Prefix, - RouteResolver.RouteResolutionResult? Routes) + private readonly struct CommittedResolution { + private readonly GlobalInvocationOptions? _options; + private readonly ActiveRoutingGraph? _graph; + private readonly PrefixResolutionResult? _prefix; + private readonly RouteResolver.RouteResolutionResult? _routes; + + private CommittedResolution( + CommittedKind kind, + GlobalInvocationOptions? options, + ActiveRoutingGraph? graph, + PrefixResolutionResult? prefix, + RouteResolver.RouteResolutionResult? routes) + { + Kind = kind; + _options = options; + _graph = graph; + _prefix = prefix; + _routes = routes; + } + + public static CommittedResolution Ambient() => + new(CommittedKind.Ambient, options: null, graph: null, prefix: null, routes: null); + + public static CommittedResolution Ambiguous( + GlobalInvocationOptions options, + ActiveRoutingGraph graph, + PrefixResolutionResult prefix) => + new(CommittedKind.Ambiguous, options, graph, prefix, routes: null); + + public static CommittedResolution Help( + GlobalInvocationOptions options, + ActiveRoutingGraph graph, + PrefixResolutionResult prefix) => + new(CommittedKind.Help, options, graph, prefix, routes: null); + + public static CommittedResolution Routed( + GlobalInvocationOptions options, + ActiveRoutingGraph graph, + PrefixResolutionResult prefix, + RouteResolver.RouteResolutionResult routes) => + new(CommittedKind.Routed, options, graph, prefix, routes); + + public CommittedKind Kind { get; } + + public GlobalInvocationOptions Options => + _options ?? throw new InvalidOperationException("An ambient resolution captures no global options."); + + public ActiveRoutingGraph Graph => + _graph ?? throw new InvalidOperationException("An ambient resolution captures no routing graph."); + + public PrefixResolutionResult Prefix => + _prefix ?? throw new InvalidOperationException("An ambient resolution captures no prefix result."); + + public RouteResolver.RouteResolutionResult Routes => + _routes ?? throw new InvalidOperationException($"A {Kind} resolution captures no route match."); + public bool IsProtocolPassthrough => - Kind == CommittedKind.Routed && Routes?.Match?.Route.Command.IsProtocolPassthrough == true; + Kind == CommittedKind.Routed && _routes?.Match?.Route.Command.IsProtocolPassthrough == true; } /// @@ -364,7 +416,7 @@ private CommittedResolution ResolveCommittedInput(IReadOnlyList inputTok { // Ambient commands win over routes sharing the same token and produce // normal terminal output, never a protocol payload. - return new CommittedResolution(CommittedKind.Ambient, Options: null, Graph: null, Prefix: null, Routes: null); + return CommittedResolution.Ambient(); } var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); @@ -383,17 +435,17 @@ private CommittedResolution ResolveCommittedInput(IReadOnlyList inputTok var prefixResolution = app.ResolveUniquePrefixes(globalOptions.RemainingTokens, graph); if (prefixResolution.IsAmbiguous) { - return new CommittedResolution(CommittedKind.Ambiguous, globalOptions, graph, prefixResolution, Routes: null); + return CommittedResolution.Ambiguous(globalOptions, graph, prefixResolution); } var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; if (resolvedOptions.HelpRequested) { - return new CommittedResolution(CommittedKind.Help, resolvedOptions, graph, prefixResolution, Routes: null); + return CommittedResolution.Help(resolvedOptions, graph, prefixResolution); } var routes = app.ResolveWithDiagnostics(resolvedOptions.RemainingTokens, graph.Routes); - return new CommittedResolution(CommittedKind.Routed, resolvedOptions, graph, prefixResolution, routes); + return CommittedResolution.Routed(resolvedOptions, graph, prefixResolution, routes); } /// @@ -432,7 +484,7 @@ private async ValueTask ExecuteInteractiveInputAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken) { - var globalOptions = committed.Options!; + var globalOptions = committed.Options; if (globalOptions.HelpRequested) { var rendered = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); @@ -442,8 +494,8 @@ private async ValueTask ExecuteInteractiveInputAsync( // Reuse the single routing-graph snapshot and route resolution captured in // ResolveCommittedInput — never re-resolve here (that reopened a TOCTOU window // against concurrent routing-graph invalidation). - var activeGraph = committed.Graph!.Value; - var resolution = committed.Routes!.Value; + var activeGraph = committed.Graph; + var resolution = committed.Routes; var match = resolution.Match; if (match is not null) { From 78117f2a26076d1ac58c0d01b736207ad15f72d9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 11:54:57 -0400 Subject: [PATCH 24/32] fix: share the hosted-ANSI capability gate with advanced progress Review finding (quality): the hosted-ANSI fallback added for marks (a4d945a) was never applied to the OSC 9;4 progress gate, so the two emitters diverged on the exact bug it fixed - a hosted client advertising ANSI purely through capability flags (identity inference, control messages) got marks but no advanced progress. The fallback now lives in a shared TerminalAnsiCapability helper consumed by both emitters, and the presenter evaluates it per event instead of freezing IsAnsiEnabled at construction - aligning progress with the marks per-cycle re-resolution contract for mid-session advertisement. Red-first regression: hosted client, AnsiMode.Auto, capabilities inferred from a Windows Terminal identity, no AnsiSupport override -> OSC 9;4 emitted. --- .../ConsoleReplInteractionPresenter.cs | 8 +++++- .../Terminal/ShellIntegrationMarkEmitter.cs | 19 +------------- .../Terminal/TerminalAnsiCapability.cs | 26 +++++++++++++++++++ ...plInteractionPresenter_AdvancedProgress.cs | 26 +++++++++++++++++++ 4 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 src/Repl.Core/Terminal/TerminalAnsiCapability.cs diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index 3c33870..1c5fcfa 100644 --- a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs @@ -9,6 +9,7 @@ internal sealed class ConsoleReplInteractionPresenter( private const string OscPrefix = "\x1b]9;4;"; private const string Bell = "\x07"; private readonly InteractionOptions _options = options; + private readonly OutputOptions? _outputOptions = outputOptions; private readonly bool _rewriteProgress = !Console.IsOutputRedirected || ReplSessionIO.IsSessionActive; private readonly bool _useAnsi = outputOptions?.IsAnsiEnabled() ?? false; private readonly AnsiPalette? _palette = outputOptions is not null && outputOptions.IsAnsiEnabled() @@ -159,7 +160,12 @@ private async ValueTask TryWriteAdvancedProgressAsync(ReplProgressEvent progress private bool ShouldEmitAdvancedProgress() { - if (ReplSessionIO.IsProtocolPassthrough || !_useAnsi || !IsInteractiveTerminalSession()) + // Evaluated per event (not frozen at construction) through the shared ANSI gate, + // so a hosted client advertising ANSI via capability flags alone — or mid-session — + // gets the same treatment as shell-integration marks. + var ansiCapable = _outputOptions is { } outputOptions + && TerminalAnsiCapability.IsAnsiCapableForTerminalSequences(outputOptions); + if (ReplSessionIO.IsProtocolPassthrough || !ansiCapable || !IsInteractiveTerminalSession()) { return false; } diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 0f36374..6fea456 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -213,7 +213,7 @@ private static ShellIntegrationGate ResolveGate(TerminalIntegrationOptions? opti return ShellIntegrationGate.ProtocolPassthrough; } - if (!IsAnsiCapableForMarks(outputOptions)) + if (!TerminalAnsiCapability.IsAnsiCapableForTerminalSequences(outputOptions)) { return ShellIntegrationGate.AnsiUnsupported; } @@ -242,23 +242,6 @@ private static ShellIntegrationGate ResolveGate(TerminalIntegrationOptions? opti private static bool SessionAdvertisesShellIntegration() => ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ShellIntegrationMarks); - // A hosted client can advertise ANSI purely through capability flags (identity - // inference, control messages, TerminalSessionOverrides) without the AnsiSupport - // override; honor that instead of IsAnsiEnabled's fallback to the server console's - // redirection state. Explicit opt-outs (AnsiSupport=false, AnsiMode.Never) still win. - private static bool IsAnsiCapableForMarks(OutputOptions outputOptions) - { - if (outputOptions.IsAnsiEnabled()) - { - return true; - } - - return ReplSessionIO.IsSessionActive - && ReplSessionIO.AnsiSupport is null - && outputOptions.AnsiMode != AnsiMode.Never - && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.Ansi); - } - // Same session-boundary rule as ResolveEnabled: the 633 dialect is chosen from the // client-reported identity for hosted sessions, from the environment locally. private static bool IsVsCodeBackend() => diff --git a/src/Repl.Core/Terminal/TerminalAnsiCapability.cs b/src/Repl.Core/Terminal/TerminalAnsiCapability.cs new file mode 100644 index 0000000..0a3935d --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalAnsiCapability.cs @@ -0,0 +1,26 @@ +namespace Repl; + +/// +/// Shared ANSI gate for terminal-sequence emitters (shell-integration marks, advanced +/// progress). A hosted client can advertise ANSI purely through capability flags +/// (identity inference, control messages, TerminalSessionOverrides) without ever +/// setting the AnsiSupport override; honoring that flag avoids falling back to +/// 's view of the server console's redirection +/// state. Explicit opt-outs (AnsiSupport=false, ) +/// still win. +/// +internal static class TerminalAnsiCapability +{ + public static bool IsAnsiCapableForTerminalSequences(OutputOptions outputOptions) + { + if (outputOptions.IsAnsiEnabled()) + { + return true; + } + + return ReplSessionIO.IsSessionActive + && ReplSessionIO.AnsiSupport is null + && outputOptions.AnsiMode != AnsiMode.Never + && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.Ansi); + } +} diff --git a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs index 32db76a..1f9190d 100644 --- a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs +++ b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs @@ -96,6 +96,32 @@ await presenter.PresentAsync( harness.RawOutput.Should().NotContain("\u001b]9;4;"); } + [TestMethod] + [Description("A hosted client that advertises ANSI purely through capability flags (identity inference, no AnsiSupport override) gets advanced progress in Auto mode — the same hosted-ANSI fallback the shell-integration marks honor.")] + public async Task When_HostedClientAdvertisesAnsiViaCapabilities_Then_AdvancedProgressIsEmitted() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + var harness = new TerminalHarness(cols: 80, rows: 12); + var presenter = new ConsoleReplInteractionPresenter( + new InteractionOptions { AdvancedProgressMode = AdvancedProgressMode.Auto }, + new OutputOptions { AnsiMode = AnsiMode.Auto }); + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null); + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + + await presenter.PresentAsync( + new ReplProgressEvent("Downloading", Percent: 42), + CancellationToken.None); + + harness.RawOutput.Should().Contain("]9;4;1;42"); + } + [TestMethod] [Description("Auto mode emits advanced progress for known terminals such as Windows Terminal.")] public async Task When_AdvancedProgressAuto_And_WindowsTerminalDetected_Then_PresenterEmitsOscSequence() From 04eded10fa84c1fb29f7ba5e8942964da93f2a8d Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 11:58:53 -0400 Subject: [PATCH 25/32] test: pin the no-marks guarantee for CLI one-shot execution Review finding (contract): "one-shot / MCP stdio emits no marks" was only structural (the interactive loop owns the emitter); a refactor wiring marks into the one-shot path would have regressed protocol streams with no red test. Pins Always mode + capable hosted terminal + matched command: payload rendered, zero ]133;/]633; bytes. --- ...nteractiveSession_ShellIntegrationMarks.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 46555fe..6de9ad3 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -478,6 +478,26 @@ public void When_LineReadFailsAfterPromptMarks_Then_AbortedCommandEndIsEmitted() raw.Should().NotContain("]133;D;", because: "a read failure is an aborted cycle, not a failure exit code"); } + [TestMethod] + [Description("CLI one-shot execution emits no marks even with UseTerminalIntegration configured, Always mode, and a fully capable terminal: Repl does not own the surrounding shell prompt there, and protocol streams (MCP stdio) must stay clean. Pins the guarantee that is otherwise only structural (the interactive loop owns the emitter).")] + public void When_OneShotRunsWithIntegrationConfigured_Then_NoMarksAreEmitted() + { + using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + var sut = CreateMarkedApp(); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + using var session = ReplSessionIO.SetSession(harness.Writer, TextReader.Null); + ReplSessionIO.AnsiSupport = true; + ReplSessionIO.TerminalIdentity = "Windows Terminal"; + + var exitCode = sut.Run(["ping"]); + + exitCode.Should().Be(0); + harness.RawOutput.Should().Contain("pong"); + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + private static ReplApp CreateMarkedApp(ShellIntegrationMode mode = ShellIntegrationMode.Always) { var sut = ReplApp.Create() From 05a552a96b0ae2428f0f745d43b3225fcc1ad055 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 12:09:50 -0400 Subject: [PATCH 26/32] refactor: prompt-cycle context, shared ambient tokens, tighter signatures Review-panel LOW batch (style/quality/contract): - Loop-stable state (mark emitter, scope list, history provider, services, cancel handler) now travels as one PromptCycleContext instead of seven positional parameters through every per-cycle method. - Ambient command tokens promoted to shared constants consumed by IsAmbientCommandInvocation, TryHandleAmbientCommandAsync, and the non-interactive ambient path, so classification and dispatch cannot drift by editing one token list and not the other. - Read-only inputTokens parameters accept IReadOnlyList. - TerminalIntegrationOptions documents that ShellIntegration is re-read each prompt cycle (runtime changes take effect on the next prompt) and no longer promises unimplemented future intents. - MarkSet perf comment corrected: 633;E also formats per call, not just D-with-exit-code. --- src/Repl.Core/CoreReplApp.Execution.cs | 6 +- src/Repl.Core/CoreReplApp.Interactive.cs | 2 +- src/Repl.Core/Session/InteractiveSession.cs | 152 +++++++++--------- .../Terminal/ShellIntegrationMarkEmitter.cs | 3 +- .../Terminal/TerminalIntegrationOptions.cs | 4 +- 5 files changed, 87 insertions(+), 80 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index c80ea8a..c53ab93 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -319,7 +319,7 @@ private async ValueTask HandleEmptyInvocationAsync( CancellationToken cancellationToken) { if (options.RemainingTokens.Count == 0 - || !string.Equals(options.RemainingTokens[0], "complete", StringComparison.OrdinalIgnoreCase)) + || !string.Equals(options.RemainingTokens[0], InteractiveSession.CompleteAmbientToken, StringComparison.OrdinalIgnoreCase)) { return null; } @@ -345,11 +345,11 @@ private async ValueTask HandleEmptyInvocationAsync( var token = options.RemainingTokens[0]; AmbientCommandOutcome ambientOutcome; - if (string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(token, InteractiveSession.ExitAmbientToken, StringComparison.OrdinalIgnoreCase)) { ambientOutcome = await HandleExitAmbientCommandAsync().ConfigureAwait(false); } - else if (string.Equals(token, "..", StringComparison.Ordinal)) + else if (string.Equals(token, InteractiveSession.UpAmbientToken, StringComparison.Ordinal)) { ambientOutcome = await HandleUpAmbientCommandAsync(scopeTokens: [], isInteractiveSession: false) .ConfigureAwait(false); diff --git a/src/Repl.Core/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index 18e52aa..3c16807 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -18,7 +18,7 @@ private string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTok Interactive.GetDeepestContextScopePath(matchedPathTokens); private ValueTask TryHandleAmbientCommandAsync( - List inputTokens, + IReadOnlyList inputTokens, List scopeTokens, IServiceProvider serviceProvider, bool isInteractiveSession, diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 4b7b3bb..c287d21 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -9,6 +9,28 @@ namespace Repl; /// internal sealed class InteractiveSession(CoreReplApp app) { + // Ambient command tokens — the single vocabulary consumed by both + // IsAmbientCommandInvocation and TryHandleAmbientCommandAsync (and the + // non-interactive ambient path), so classification and dispatch can never + // drift apart by editing one list and not the other. + internal const string UpAmbientToken = ".."; + internal const string ExitAmbientToken = "exit"; + internal const string CompleteAmbientToken = "complete"; + internal const string AutocompleteAmbientToken = "autocomplete"; + internal const string HistoryAmbientToken = "history"; + + /// + /// Loop-stable state shared by every prompt cycle of one interactive session: the mark + /// emitter, the mutable scope path, and the session-scoped collaborators. Bundled so + /// the per-cycle methods don't each grow a long positional parameter list. + /// + private sealed record PromptCycleContext( + ShellIntegrationMarkEmitter Marks, + List ScopeTokens, + IHistoryProvider? HistoryProvider, + IServiceProvider ServiceProvider, + CancelKeyHandler CancelHandler); + internal bool ShouldEnterInteractive(GlobalInvocationOptions globalOptions, bool allowAuto) { if (globalOptions.InteractivePrevented) @@ -36,18 +58,20 @@ internal async ValueTask RunInteractiveSessionAsync( { using var runtimeStateScope = app.PushRuntimeState(serviceProvider, isInteractiveSession: true); using var cancelHandler = new CancelKeyHandler(); - var scopeTokens = initialScopeTokens.ToList(); - var historyProvider = serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider; + var cycle = new PromptCycleContext( + Marks: ShellIntegrationMarkEmitter.Create(app.OptionsSnapshot.TerminalIntegration, app.OptionsSnapshot.Output), + ScopeTokens: initialScopeTokens.ToList(), + HistoryProvider: serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider, + ServiceProvider: serviceProvider, + CancelHandler: cancelHandler); string? lastHistoryEntry = null; - var marks = ShellIntegrationMarkEmitter.Create(app.OptionsSnapshot.TerminalIntegration, app.OptionsSnapshot.Output); await app.ShellCompletionRuntimeInstance.HandleStartupAsync(serviceProvider, cancellationToken).ConfigureAwait(false); while (true) { cancellationToken.ThrowIfCancellationRequested(); try { - var (exit, updatedHistory) = await RunPromptCycleAsync( - marks, scopeTokens, historyProvider, lastHistoryEntry, serviceProvider, cancelHandler, cancellationToken) + var (exit, updatedHistory) = await RunPromptCycleAsync(cycle, lastHistoryEntry, cancellationToken) .ConfigureAwait(false); lastHistoryEntry = updatedHistory; if (exit) @@ -62,7 +86,7 @@ internal async ValueTask RunInteractiveSessionAsync( // code) so the terminal keeps no unterminated segment, then propagate. // No-op if the cycle was already closed (ExecuteCommittedInputAsync handles // its own exceptions), since the emitter's phase guard drops a second D. - await TryWriteCommandEndAsync(marks, exitCode: null).ConfigureAwait(false); + await TryWriteCommandEndAsync(cycle.Marks, exitCode: null).ConfigureAwait(false); throw; } } @@ -73,21 +97,12 @@ internal async ValueTask RunInteractiveSessionAsync( /// Returns whether the session should exit and the updated last-history entry. /// private async ValueTask<(bool Exit, string? LastHistoryEntry)> RunPromptCycleAsync( - ShellIntegrationMarkEmitter marks, - List scopeTokens, - IHistoryProvider? historyProvider, + PromptCycleContext cycle, string? lastHistoryEntry, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, CancellationToken cancellationToken) { - var readResult = await ReadInteractiveInputAsync( - marks, - scopeTokens, - historyProvider, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); + var marks = cycle.Marks; + var readResult = await ReadInteractiveInputAsync(cycle, cancellationToken).ConfigureAwait(false); if (readResult.Escaped) { await marks.WriteCommandEndAsync(exitCode: null).ConfigureAwait(false); @@ -110,34 +125,32 @@ internal async ValueTask RunInteractiveSessionAsync( } lastHistoryEntry = await TryAppendHistoryAsync( - historyProvider, + cycle.HistoryProvider, lastHistoryEntry, line, cancellationToken) .ConfigureAwait(false); - var outcome = await ExecuteCommittedInputAsync( - marks, line, inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + var outcome = await ExecuteCommittedInputAsync(cycle, line, inputTokens, cancellationToken) .ConfigureAwait(false); return (outcome == AmbientCommandOutcome.Exit, lastHistoryEntry); } private async ValueTask ReadInteractiveInputAsync( - ShellIntegrationMarkEmitter marks, - IReadOnlyList scopeTokens, - IHistoryProvider? historyProvider, - IServiceProvider serviceProvider, + PromptCycleContext cycle, CancellationToken cancellationToken) { - await marks.WritePromptStartAsync().ConfigureAwait(false); + var scopeTokens = cycle.ScopeTokens; + var serviceProvider = cycle.ServiceProvider; + await cycle.Marks.WritePromptStartAsync().ConfigureAwait(false); await ReplSessionIO.Output.WriteAsync(BuildPrompt(scopeTokens)).ConfigureAwait(false); await ReplSessionIO.Output.WriteAsync(' ').ConfigureAwait(false); - await marks.WriteInputStartAsync().ConfigureAwait(false); + await cycle.Marks.WriteInputStartAsync().ConfigureAwait(false); var effectiveMode = app.Autocomplete.ResolveEffectiveAutocompleteMode(serviceProvider); var renderMode = AutocompleteEngine.ResolveAutocompleteRenderMode(effectiveMode); var colorStyles = app.Autocomplete.ResolveAutocompleteColorStyles(renderMode == ConsoleLineReader.AutocompleteRenderMode.Rich); return await ConsoleLineReader.ReadLineAsync( - historyProvider, + cycle.HistoryProvider, effectiveMode == AutocompleteMode.Off ? null : (request, ct) => app.Autocomplete.ResolveAutocompleteAsync(request, scopeTokens, serviceProvider, ct), @@ -172,18 +185,16 @@ internal async ValueTask RunInteractiveSessionAsync( private async ValueTask<(AmbientCommandOutcome Outcome, int ExitCode)> DispatchInteractiveCommandAsync( CommittedResolution resolution, - List inputTokens, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, + IReadOnlyList inputTokens, + PromptCycleContext cycle, CancellationToken cancellationToken) { if (resolution.Kind == CommittedKind.Ambient) { var ambientOutcome = await TryHandleAmbientCommandAsync( inputTokens, - scopeTokens, - serviceProvider, + cycle.ScopeTokens, + cycle.ServiceProvider, isInteractiveSession: true, cancellationToken) .ConfigureAwait(false); @@ -202,25 +213,23 @@ internal async ValueTask RunInteractiveSessionAsync( // Help or Routed: both flow through the command-cancellation scope so Ctrl-C and // exit-code computation behave identically; the pre-resolved graph/match is reused. - var exitCode = await ExecuteWithCancellationAsync(resolution, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + var exitCode = await ExecuteWithCancellationAsync(resolution, cycle, cancellationToken) .ConfigureAwait(false); return (AmbientCommandOutcome.Handled, exitCode); } private async ValueTask ExecuteWithCancellationAsync( CommittedResolution resolution, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, + PromptCycleContext cycle, CancellationToken cancellationToken) { using var commandCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - SetCommandTokenOnChannel(serviceProvider, commandCts.Token); - cancelHandler.SetCommandCts(commandCts); + SetCommandTokenOnChannel(cycle.ServiceProvider, commandCts.Token); + cycle.CancelHandler.SetCommandCts(commandCts); try { - return await ExecuteInteractiveInputAsync(resolution, scopeTokens, serviceProvider, commandCts.Token) + return await ExecuteInteractiveInputAsync(resolution, cycle, commandCts.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) @@ -235,8 +244,8 @@ private async ValueTask ExecuteWithCancellationAsync( } finally { - cancelHandler.SetCommandCts(cts: null); - SetCommandTokenOnChannel(serviceProvider, default); + cycle.CancelHandler.SetCommandCts(cts: null); + SetCommandTokenOnChannel(cycle.ServiceProvider, default); } } @@ -248,15 +257,13 @@ private async ValueTask ExecuteWithCancellationAsync( /// can never disagree across a concurrent routing-graph invalidation. /// private async ValueTask ExecuteCommittedInputAsync( - ShellIntegrationMarkEmitter marks, + PromptCycleContext cycle, string line, - List inputTokens, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, + IReadOnlyList inputTokens, CancellationToken cancellationToken) { - var resolution = ResolveCommittedInput(inputTokens, scopeTokens); + var marks = cycle.Marks; + var resolution = ResolveCommittedInput(inputTokens, cycle.ScopeTokens); // A protocol-passthrough command turns the output stream into a protocol // channel: no mark may precede or trail its payload. A/B were already @@ -273,8 +280,7 @@ private async ValueTask ExecuteCommittedInputAsync( int exitCode; try { - (outcome, exitCode) = await DispatchInteractiveCommandAsync( - resolution, inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + (outcome, exitCode) = await DispatchInteractiveCommandAsync(resolution, inputTokens, cycle, cancellationToken) .ConfigureAwait(false); } catch when (isProtocolPassthrough) @@ -462,11 +468,11 @@ private bool IsAmbientCommandInvocation(IReadOnlyList inputTokens) var token = inputTokens[0]; return CoreReplApp.IsHelpToken(token) - || (inputTokens.Count == 1 && string.Equals(token, "..", StringComparison.Ordinal)) - || (inputTokens.Count == 1 && string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) - || string.Equals(token, "complete", StringComparison.OrdinalIgnoreCase) - || string.Equals(token, "autocomplete", StringComparison.OrdinalIgnoreCase) - || string.Equals(token, "history", StringComparison.OrdinalIgnoreCase) + || (inputTokens.Count == 1 && string.Equals(token, UpAmbientToken, StringComparison.Ordinal)) + || (inputTokens.Count == 1 && string.Equals(token, ExitAmbientToken, StringComparison.OrdinalIgnoreCase)) + || string.Equals(token, CompleteAmbientToken, StringComparison.OrdinalIgnoreCase) + || string.Equals(token, AutocompleteAmbientToken, StringComparison.OrdinalIgnoreCase) + || string.Equals(token, HistoryAmbientToken, StringComparison.OrdinalIgnoreCase) || app.OptionsSnapshot.AmbientCommands.CustomCommands.ContainsKey(token); } @@ -480,8 +486,7 @@ private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, C private async ValueTask ExecuteInteractiveInputAsync( CommittedResolution committed, - List scopeTokens, - IServiceProvider serviceProvider, + PromptCycleContext cycle, CancellationToken cancellationToken) { var globalOptions = committed.Options; @@ -504,16 +509,15 @@ private async ValueTask ExecuteInteractiveInputAsync( // Same execution contract as the CLI one-shot path — hosted-capability guard, // protocol-passthrough scope, and stream isolation — so a handler probing // IsProtocolPassthrough observes the same value in both modes. - return await app.ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) + return await app.ExecuteProtocolPassthroughCommandAsync(match, globalOptions, cycle.ServiceProvider, cancellationToken) .ConfigureAwait(false); } - var (exitCode, _) = await app.ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); + var (exitCode, _) = await app.ExecuteMatchedCommandAsync(match, globalOptions, cycle.ServiceProvider, cycle.ScopeTokens, cancellationToken).ConfigureAwait(false); return exitCode; } - return await HandleUnmatchedInteractiveInputAsync( - activeGraph, resolution, globalOptions, scopeTokens, serviceProvider, cancellationToken) + return await HandleUnmatchedInteractiveInputAsync(activeGraph, resolution, globalOptions, cycle, cancellationToken) .ConfigureAwait(false); } @@ -525,10 +529,10 @@ private async ValueTask HandleUnmatchedInteractiveInputAsync( ActiveRoutingGraph activeGraph, RouteResolver.RouteResolutionResult resolution, GlobalInvocationOptions globalOptions, - List scopeTokens, - IServiceProvider serviceProvider, + PromptCycleContext cycle, CancellationToken cancellationToken) { + var serviceProvider = cycle.ServiceProvider; var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, app.OptionsSnapshot.Parsing); if (contextMatch is not null) { @@ -545,8 +549,8 @@ private async ValueTask HandleUnmatchedInteractiveInputAsync( return 1; } - scopeTokens.Clear(); - scopeTokens.AddRange(globalOptions.RemainingTokens); + cycle.ScopeTokens.Clear(); + cycle.ScopeTokens.AddRange(globalOptions.RemainingTokens); if (contextMatch.Context.Banner is { } contextBanner && app.ShouldRenderBanner(globalOptions.OutputFormat)) { @@ -574,7 +578,7 @@ private async ValueTask HandleUnmatchedInteractiveInputAsync( "MA0051:Method is too long", Justification = "Ambient command routing keeps dispatch table explicit and easy to scan.")] internal async ValueTask TryHandleAmbientCommandAsync( - List inputTokens, + IReadOnlyList inputTokens, List scopeTokens, IServiceProvider serviceProvider, bool isInteractiveSession, @@ -599,17 +603,17 @@ internal async ValueTask TryHandleAmbientCommandAsync( return helpRendered ? AmbientCommandOutcome.Handled : AmbientCommandOutcome.HandledError; } - if (inputTokens.Count == 1 && string.Equals(token, "..", StringComparison.Ordinal)) + if (inputTokens.Count == 1 && string.Equals(token, UpAmbientToken, StringComparison.Ordinal)) { return await HandleUpAmbientCommandAsync(scopeTokens, isInteractiveSession).ConfigureAwait(false); } - if (inputTokens.Count == 1 && string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) + if (inputTokens.Count == 1 && string.Equals(token, ExitAmbientToken, StringComparison.OrdinalIgnoreCase)) { return await HandleExitAmbientCommandAsync().ConfigureAwait(false); } - if (string.Equals(token, "complete", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(token, CompleteAmbientToken, StringComparison.OrdinalIgnoreCase)) { var completionSucceeded = await HandleCompletionAmbientCommandAsync( inputTokens.Skip(1).ToArray(), @@ -620,7 +624,7 @@ internal async ValueTask TryHandleAmbientCommandAsync( return completionSucceeded ? AmbientCommandOutcome.Handled : AmbientCommandOutcome.HandledError; } - if (string.Equals(token, "autocomplete", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(token, AutocompleteAmbientToken, StringComparison.OrdinalIgnoreCase)) { return await HandleAutocompleteAmbientCommandAsync( inputTokens.Skip(1).ToArray(), @@ -629,7 +633,7 @@ internal async ValueTask TryHandleAmbientCommandAsync( .ConfigureAwait(false); } - if (string.Equals(token, "history", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(token, HistoryAmbientToken, StringComparison.OrdinalIgnoreCase)) { return await HandleHistoryAmbientCommandAsync( inputTokens.Skip(1).ToArray(), @@ -878,7 +882,9 @@ internal string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTo : matchedPathTokens.Take(longestPrefixLength).ToArray(); } - private string BuildPrompt(IReadOnlyList scopeTokens) + // List rather than IReadOnlyList: the only caller passes the cycle's scope list + // and CA1859 insists on the concrete type for the string.Join fast path. + private string BuildPrompt(List scopeTokens) { var basePrompt = app.OptionsSnapshot.Interactive.Prompt; if (scopeTokens.Count == 0) diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 6fea456..e1ddaa8 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -23,7 +23,8 @@ private enum Phase private const string Bell = "\x07"; // The A/B/C/D-no-code marks are constant per backend; precomputed so the per-prompt - // path allocates no throw-away interpolated strings (only D-with-exit-code does). + // path allocates no throw-away interpolated strings. Only the variable-payload marks + // still format per call: D-with-exit-code and the 633;E command-line report. private static readonly MarkSet Osc133 = new("\x1b]133;A\x07", "\x1b]133;B\x07", "\x1b]133;C\x07", "\x1b]133;D\x07"); private static readonly MarkSet Osc633 = new("\x1b]633;A\x07", "\x1b]633;B\x07", "\x1b]633;C\x07", "\x1b]633;D\x07"); diff --git a/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs b/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs index d76233f..f82ebc9 100644 --- a/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs +++ b/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs @@ -2,13 +2,13 @@ namespace Repl.Terminal; /// /// Options for the terminal-integration layer enabled by UseTerminalIntegration. -/// Additional integration intents (hyperlinks, notifications, theme probing) will be -/// added here as they are implemented. /// public sealed class TerminalIntegrationOptions { /// /// Gets or sets how shell-integration lifecycle marks are emitted in interactive mode. + /// The value is re-read at the start of every prompt cycle, so changing it at runtime + /// takes effect on the next prompt. /// public ShellIntegrationMode ShellIntegration { get; set; } = ShellIntegrationMode.Auto; } From 2abc3679789edc913a788be062ca86c6cdf637d2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 12:16:07 -0400 Subject: [PATCH 27/32] test: dedupe harness setup, harden fault injection, drop brittle couplings Review-panel LOW batch (style/contract/skeptic): - RunInteractiveSession and CaptureInteractiveRun now share one session-scope core, and the helper no longer sits between test methods. - NeutralTerminalEnvironment moved to shared TerminalTestEnvironments, removing the per-class copies. - MarkFailingWriter observes every write shape through a rolling-window detector and records Threw; the mask-prevention test asserts it fired, so the fault injection cannot silently stop and pass vacuously. - The mask-prevention assertion no longer couples to the exact internal exception message (type + stable "--limit" marker instead). - Magic Substring(index, 8) windows replaced by MarkPayloadAt (slice to the BEL terminator). - New mark test: an ambiguous prefix reports D;1 inside the normal lifecycle (previously untested exit-code branch). --- ...nteractiveSession_ShellIntegrationMarks.cs | 205 +++++++++++------- .../Given_ShellIntegrationMarkEmitter.cs | 40 ++-- .../Terminal/TerminalTestEnvironments.cs | 20 ++ 3 files changed, 163 insertions(+), 102 deletions(-) create mode 100644 src/Repl.Tests/Terminal/TerminalTestEnvironments.cs diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 6de9ad3..9868c1a 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -6,20 +6,12 @@ namespace Repl.Tests; [DoNotParallelize] public sealed class Given_InteractiveSession_ShellIntegrationMarks { - private static readonly (string Name, string? Value)[] NeutralTerminalEnvironment = - [ - ("TMUX", null), - ("TERM", null), - ("WT_SESSION", null), - ("ConEmuANSI", null), - ("TERM_PROGRAM", null), - ]; [TestMethod] [Description("A successful interactive command is wrapped by the full lifecycle: prompt start, input start, output start, command output, and command end with exit code 0, then the next prompt starts a new cycle.")] public void When_CommandSucceeds_Then_PromptInputOutputAndEndMarksWrapTheCommand() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -43,7 +35,7 @@ public void When_CommandSucceeds_Then_PromptInputOutputAndEndMarksWrapTheCommand [Description("A command returning an error result reports exit code 1 in the command-end mark so terminals decorate the command as failed.")] public void When_CommandReturnsError_Then_CommandEndReportsExitCodeOne() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("boom", () => Results.Error("boom-failed", "nope")); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -57,7 +49,7 @@ public void When_CommandReturnsError_Then_CommandEndReportsExitCodeOne() [Description("An unknown command resolves to a route-resolution failure and reports exit code 1 in the command-end mark.")] public void When_UnknownCommandIsEntered_Then_CommandEndReportsExitCodeOne() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -67,11 +59,27 @@ public void When_UnknownCommandIsEntered_Then_CommandEndReportsExitCodeOne() raw.Should().Contain("]133;D;1"); } + [TestMethod] + [Description("An ambiguous command prefix renders its error inside the normal lifecycle and reports exit code 1 in the command-end mark, like any other failed input.")] + public void When_AmbiguousPrefixIsCommitted_Then_CommandEndReportsExitCodeOne() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Map("gabu", () => "bu"); + sut.Map("gazo", () => "zo"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ga\rexit\r"); + + raw.Should().Contain("Ambiguous command prefix"); + raw.Should().Contain("]133;D;1"); + } + [TestMethod] [Description("Ambient commands such as help run inside the same command lifecycle: their output lands between output-start and a successful command-end mark.")] public void When_HelpAmbientCommandRuns_Then_MarksWrapHelpOutput() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong").WithDescription("Answers with pong."); var harness = new TerminalHarness(cols: 80, rows: 24); @@ -90,7 +98,7 @@ public void When_HelpAmbientCommandRuns_Then_MarksWrapHelpOutput() [Description("Committing an empty line aborts the cycle: command-end is reported without an exit code and no output-start mark is emitted before it.")] public void When_EmptyLineIsCommitted_Then_CommandEndsWithoutExitCodeAndWithoutOutputMark() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -100,7 +108,7 @@ public void When_EmptyLineIsCommitted_Then_CommandEndsWithoutExitCodeAndWithoutO var firstCommandEnd = raw.IndexOf("]133;D", StringComparison.Ordinal); var firstOutputStart = raw.IndexOf("]133;C", StringComparison.Ordinal); firstCommandEnd.Should().BeGreaterThanOrEqualTo(0); - raw.Substring(firstCommandEnd, 8).Should().NotContain("D;"); + MarkPayloadAt(raw, firstCommandEnd).Should().NotContain("D;", because: "an aborted cycle reports D without an exit-code parameter"); firstOutputStart.Should().BeGreaterThan(firstCommandEnd, because: "the only output-start mark belongs to the later exit command"); } @@ -108,7 +116,7 @@ public void When_EmptyLineIsCommitted_Then_CommandEndsWithoutExitCodeAndWithoutO [Description("Escaping at an empty prompt abandons the cycle: command-end is reported without an exit code, matching the FinalTerm aborted-command form.")] public void When_EscapeAbandonsThePrompt_Then_CommandEndsWithoutExitCode() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -117,14 +125,14 @@ public void When_EscapeAbandonsThePrompt_Then_CommandEndsWithoutExitCode() var firstCommandEnd = raw.IndexOf("]133;D", StringComparison.Ordinal); firstCommandEnd.Should().BeGreaterThanOrEqualTo(0); - raw.Substring(firstCommandEnd, 8).Should().NotContain("D;"); + MarkPayloadAt(raw, firstCommandEnd).Should().NotContain("D;", because: "an aborted cycle reports D without an exit-code parameter"); } [TestMethod] [Description("The exit ambient command closes its own cycle: the final command-end mark reports exit code 0 and no mark follows it.")] public void When_ExitCommandRuns_Then_FinalCommandEndIsEmittedBeforeSessionEnds() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -140,7 +148,7 @@ public void When_ExitCommandRuns_Then_FinalCommandEndIsEmittedBeforeSessionEnds( [Description("A handler cancelled mid-command keeps the Cancelled. message and reports exit code 130 (128+SIGINT), the shell convention terminals interpret as an interrupted command.")] public void When_HandlerThrowsOperationCanceled_Then_CancelledLineIsPrintedAndExitCodeIs130() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("boom", string () => throw new OperationCanceledException()); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -155,7 +163,7 @@ public void When_HandlerThrowsOperationCanceled_Then_CancelledLineIsPrintedAndEx [Description("A session reporting a VS Code terminal identity selects the OSC 633 backend, which reports the committed command line with 633;E so command detection is independent of screen scraping.")] public void When_VsCodeTerminalIsDetected_Then_CommandLineIsReportedWithOsc633E() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(ShellIntegrationMode.Auto); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -175,7 +183,7 @@ public void When_VsCodeTerminalIsDetected_Then_CommandLineIsReportedWithOsc633E( [Description("A failed completion ambient command (complete without --target) reports exit code 1 in the command-end mark instead of decorating the failure as success.")] public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -190,7 +198,7 @@ public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() [Description("An ambient help invocation that fails to render (unknown output format) reports exit code 1 in the command-end mark, matching the non-ambient --help path.")] public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsExitCodeOne() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -204,7 +212,7 @@ public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsExitCodeO [Description("A passthrough route invoked with a leading global option (--json) is still classified as passthrough by the single committed-input resolution, so no output marks wrap its payload.")] public void When_PassthroughCommandHasGlobalOption_Then_NoOutputMarksWrapThePayload() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -219,7 +227,7 @@ public void When_PassthroughCommandHasGlobalOption_Then_NoOutputMarksWrapThePayl [Description("A protocol-passthrough command run interactively gets no output-start or command-end marks: OSC bytes must never precede or trail a protocol payload on the same stream.")] public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksWrapTheProtocolStream() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -239,7 +247,7 @@ public void When_ProtocolPassthroughCommandRunsInteractively_Then_NoOutputMarksW [Description("Once a protocol-passthrough invocation dispatches, no command-end mark may be emitted even on failure: an error exit cannot prove the payload never started, so the cycle is abandoned silently.")] public void When_PassthroughCommandFailsValidation_Then_CycleIsAbandonedWithoutMarks() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -255,7 +263,7 @@ public void When_PassthroughCommandFailsValidation_Then_CycleIsAbandonedWithoutM [Description("A passthrough handler that emits bytes and then returns a nonzero exit gets no trailing command-end mark: OSC bytes must never follow a protocol payload, whatever the exit code.")] public void When_PassthroughHandlerExitsNonZero_Then_NoMarkTrailsThePayload() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("serve", (IReplIoContext _) => Results.Exit(7)).AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -270,7 +278,7 @@ public void When_PassthroughHandlerExitsNonZero_Then_NoMarkTrailsThePayload() [Description("A protocol-passthrough route dispatched from the interactive loop runs under the same passthrough contract as CLI one-shot execution: the handler observes an active protocol-passthrough scope, so the stdout/stderr/session isolation contract holds in both modes.")] public void When_PassthroughCommandRunsInteractively_Then_HandlerObservesPassthroughScope() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); bool? observedPassthrough = null; sut.Map( @@ -293,7 +301,7 @@ public void When_PassthroughCommandRunsInteractively_Then_HandlerObservesPassthr [Description("In a hosted interactive session, a protocol-passthrough route whose handler cannot run hosted (no IReplIoContext parameter) is rejected with the same error as the CLI one-shot path, instead of silently running without the isolation contract.")] public void When_HostedInteractivePassthroughLacksIoContext_Then_HostedGuardRejectsLikeCli() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); var handlerRan = false; sut.Map( @@ -316,7 +324,7 @@ public void When_HostedInteractivePassthroughLacksIoContext_Then_HostedGuardReje [Description("An ambient command that shares its token with a protocol-passthrough route is handled ambient-first and keeps the normal lifecycle: output-start and a command-end mark wrap its terminal output.")] public void When_AmbientCommandSharesTokenWithPassthroughRoute_Then_AmbientLifecycleApplies() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("history", () => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -332,7 +340,7 @@ public void When_AmbientCommandSharesTokenWithPassthroughRoute_Then_AmbientLifec [Description("A prefix-abbreviated protocol-passthrough route (ser -> serve) is classified as passthrough by the single committed-input resolution — prefix expansion and the route match share one graph snapshot — so no output marks wrap its payload.")] public void When_PrefixAbbreviatedPassthroughRuns_Then_NoOutputMarksWrapThePayload() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -348,7 +356,7 @@ public void When_PrefixAbbreviatedPassthroughRuns_Then_NoOutputMarksWrapThePaylo [Description("Requesting --help on a protocol-passthrough route only renders help, so the normal lifecycle applies: the help cycle gets output-start and a successful command-end mark.")] public void When_PassthroughRouteRequestsHelp_Then_LifecycleMarksApplyNormally() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("serve", () => "protocol-payload").AsProtocolPassthrough(); var harness = new TerminalHarness(cols: 80, rows: 24); @@ -363,7 +371,7 @@ public void When_PassthroughRouteRequestsHelp_Then_LifecycleMarksApplyNormally() [Description("A dispatch that throws (history with a non-numeric --limit) still closes the lifecycle with a failed command-end mark so the terminal never keeps an unterminated command segment.")] public void When_AmbientCommandThrows_Then_CommandEndStillReportsFailure() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -398,7 +406,7 @@ public void When_IntegrationIsNotConfigured_Then_NoMarksAreEmitted() [Description("Interaction events written by a handler (status lines) do not open nested lifecycle cycles: exactly one prompt-start mark per loop iteration.")] public void When_HandlerWritesInteractionEvents_Then_OnlyLoopPromptsEmitMarks() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("work", async (IReplInteractionChannel channel) => { @@ -417,7 +425,7 @@ public void When_HandlerWritesInteractionEvents_Then_OnlyLoopPromptsEmitMarks() [Description("When the command-end mark write itself fails (torn-down transport), the original dispatch exception must surface, not the mark-write failure that only happened during cleanup.")] public void When_CommandEndMarkWriteFails_Then_OriginalExceptionSurfaces() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -427,44 +435,16 @@ public void When_CommandEndMarkWriteFails_Then_OriginalExceptionSurfaces() var thrown = CaptureInteractiveRun(writer, sut, "history --limit abc\r"); - thrown.Should().BeOfType(); - thrown!.Message.Should().Contain("history --limit must be a positive integer"); - } - - private static InvalidOperationException? CaptureInteractiveRun(TextWriter writer, ReplApp sut, string typedInput) - { - var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); - var previousReader = ReplSessionIO.KeyReader; - using var scope = ReplSessionIO.SetSession(writer, TextReader.Null); - try - { - ReplSessionIO.KeyReader = keyReader; - ReplSessionIO.WindowSize = (80, 12); - ReplSessionIO.AnsiSupport = true; - ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; - try - { - _ = sut.Run([]); - return null; - } - catch (InvalidOperationException ex) - { - // Only the expected ambient-failure exception is captured for assertion; - // any other type escapes as a genuine test failure. - return ex; - } - } - finally - { - ReplSessionIO.KeyReader = previousReader; - } + writer.Threw.Should().BeTrue(because: "the fault injection must actually fire for this test to prove anything"); + thrown.Should().BeOfType(because: "the ambient handler's failure is the original exception"); + thrown!.Message.Should().Contain("--limit", because: "the exception must be the ambient --limit failure, not the mark-write IOException"); } [TestMethod] [Description("If the line read throws after the prompt marks are written (A/B), the loop closes the open cycle with an aborted command-end mark (no exit code) before the exception propagates, so the terminal keeps no unterminated command segment.")] public void When_LineReadFailsAfterPromptMarks_Then_AbortedCommandEndIsEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -482,7 +462,7 @@ public void When_LineReadFailsAfterPromptMarks_Then_AbortedCommandEndIsEmitted() [Description("CLI one-shot execution emits no marks even with UseTerminalIntegration configured, Always mode, and a fully capable terminal: Repl does not own the surrounding shell prompt there, and protocol streams (MCP stdio) must stay clean. Pins the guarantee that is otherwise only structural (the interactive loop owns the emitter).")] public void When_OneShotRunsWithIntegrationConfigured_Then_NoMarksAreEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); sut.Map("ping", () => "pong"); var harness = new TerminalHarness(cols: 80, rows: 12); @@ -513,14 +493,33 @@ private static string RunInteractiveSession( string typedInput, string? terminalIdentity = null, bool swallowRunExceptions = false) + { + _ = RunInteractiveSessionCore( + harness.Writer, (harness.Cols, harness.Rows), sut, typedInput, terminalIdentity, swallowRunExceptions); + return harness.RawOutput; + } + + private static InvalidOperationException? CaptureInteractiveRun(TextWriter writer, ReplApp sut, string typedInput) => + RunInteractiveSessionCore( + writer, size: (80, 12), sut, typedInput, terminalIdentity: null, swallowRunExceptions: true); + + // Single session-scope setup shared by the raw-output and captured-exception entry + // points, so the two cannot drift apart on session metadata. + private static InvalidOperationException? RunInteractiveSessionCore( + TextWriter writer, + (int Cols, int Rows) size, + ReplApp sut, + string typedInput, + string? terminalIdentity, + bool swallowRunExceptions) { var keyReader = new FakeKeyReader(typedInput.Select(ToKeyInfo).ToArray()); var previousReader = ReplSessionIO.KeyReader; - using var scope = ReplSessionIO.SetSession(harness.Writer, TextReader.Null); + using var scope = ReplSessionIO.SetSession(writer, TextReader.Null); try { ReplSessionIO.KeyReader = keyReader; - ReplSessionIO.WindowSize = (harness.Cols, harness.Rows); + ReplSessionIO.WindowSize = size; ReplSessionIO.AnsiSupport = true; ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; if (terminalIdentity is not null) @@ -532,12 +531,14 @@ private static string RunInteractiveSession( { _ = sut.Run([]); } - catch (InvalidOperationException) when (swallowRunExceptions) + catch (InvalidOperationException ex) when (swallowRunExceptions) { - // The test asserts on the marks emitted before the crash propagated. + // Only the expected ambient-failure exception type is captured for + // assertion; any other type escapes as a genuine test failure. + return ex; } - return harness.RawOutput; + return null; } finally { @@ -545,6 +546,16 @@ private static string RunInteractiveSession( } } + /// + /// The payload of the OSC mark starting at : everything up to + /// its BEL terminator, so assertions cover exactly one mark without a magic length. + /// + private static string MarkPayloadAt(string raw, int index) + { + var bell = raw.IndexOf('\a', index); + return bell < 0 ? raw[index..] : raw[index..bell]; + } + private static ConsoleKeyInfo ToKeyInfo(char ch) => ch switch { @@ -558,34 +569,72 @@ private static ConsoleKey CharToConsoleKey(char ch) => ? ConsoleKey.A + (char.ToUpperInvariant(ch) - 'A') : ConsoleKey.Spacebar; - // Delegates to an inner writer but throws when asked to write a fragment (e.g. the - // command-end mark), simulating a transport torn down mid-command. + // Delegates to an inner writer but throws once the observed output contains the given + // fragment (e.g. the command-end mark), simulating a transport torn down mid-command. + // Every write shape funnels through the same rolling-window detector, so the fault + // injection keeps firing even if the emitter changes its write granularity; tests + // assert Threw so a silent stop of the injection cannot pass vacuously. private sealed class MarkFailingWriter(TextWriter inner, string failOn) : TextWriter { + private readonly System.Text.StringBuilder _window = new(); + + public bool Threw { get; private set; } + public override System.Text.Encoding Encoding => inner.Encoding; + public override void Write(char value) + { + ThrowIfFragmentObserved(value.ToString()); + inner.Write(value); + } + + public override void Write(char[] buffer, int index, int count) + { + ThrowIfFragmentObserved(new string(buffer, index, count)); + inner.Write(buffer, index, count); + } + public override void Write(string? value) { - ThrowIfFragment(value); + ThrowIfFragmentObserved(value); inner.Write(value); } + public override Task WriteAsync(char value) + { + ThrowIfFragmentObserved(value.ToString()); + return inner.WriteAsync(value); + } + public override Task WriteAsync(string? value) { - ThrowIfFragment(value); + ThrowIfFragmentObserved(value); return inner.WriteAsync(value); } public override Task WriteLineAsync(string? value) { - ThrowIfFragment(value); + ThrowIfFragmentObserved(value); return inner.WriteLineAsync(value); } - private void ThrowIfFragment(string? value) + private void ThrowIfFragmentObserved(string? value) { - if (value is not null && value.Contains(failOn, StringComparison.Ordinal)) + if (string.IsNullOrEmpty(value)) + { + return; + } + + _window.Append(value); + var overflow = _window.Length - (failOn.Length * 2); + if (overflow > 0) + { + _window.Remove(0, overflow); + } + + if (_window.ToString().Contains(failOn, StringComparison.Ordinal)) { + Threw = true; throw new IOException("simulated transport failure on mark write"); } } diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index 8be8e10..b11d277 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -6,20 +6,12 @@ namespace Repl.Tests; [DoNotParallelize] public sealed class Given_ShellIntegrationMarkEmitter { - private static readonly (string Name, string? Value)[] NeutralTerminalEnvironment = - [ - ("TMUX", null), - ("TERM", null), - ("WT_SESSION", null), - ("ConEmuANSI", null), - ("TERM_PROGRAM", null), - ]; [TestMethod] [Description("Always mode emits the full OSC 133 lifecycle in order: prompt start (A), input start (B), output start (C), command end with exit code (D).")] public async Task When_ModeAlways_AndAnsiSession_Then_LifecycleMarksAreEmittedInOrder() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -49,7 +41,7 @@ public async Task When_MarksAreEmitted_Then_FullOscFramingIsExact() { var esc = ((char)0x1b).ToString(); var bel = ((char)0x07).ToString(); - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -75,7 +67,7 @@ public async Task When_VsCodeBackend_Then_Full633FramingIsExact() { var esc = ((char)0x1b).ToString(); var bel = ((char)0x07).ToString(); - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -172,7 +164,7 @@ public async Task When_HostedSessionAdvertisesMarks_AndServerRunsInsideVsCode_Th [Description("A session reporting a VS Code identity selects the OSC 633 backend and reports the command line with 633;E between input start and output start.")] public async Task When_ModeAuto_AndVsCodeDetected_Then_Osc633MarksAreEmittedIncludingCommandLine() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -225,7 +217,7 @@ public async Task When_ModeAuto_AndTmuxDetected_Then_NoMarksAreEmitted() [Description("Auto mode honors a hosted session that advertises ShellIntegrationMarks through its terminal identity, without any local environment hint.")] public async Task When_ModeAuto_AndHostedSessionAdvertisesShellIntegrationMarks_Then_MarksAreEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -243,7 +235,7 @@ public async Task When_ModeAuto_AndHostedSessionAdvertisesShellIntegrationMarks_ [Description("A hosted session reporting a VS Code identity selects the OSC 633 backend even without TERM_PROGRAM in the host process environment.")] public async Task When_HostedSessionReportsVsCodeIdentity_Then_Osc633BackendIsSelected() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -262,7 +254,7 @@ public async Task When_HostedSessionReportsVsCodeIdentity_Then_Osc633BackendIsSe [Description("Protocol passthrough (raw bytes piped to stdout) suppresses every mark regardless of mode: OSC bytes must never corrupt protocol streams.")] public async Task When_ProtocolPassthroughIsActive_Then_NoMarksAreEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -281,7 +273,7 @@ public async Task When_ProtocolPassthroughIsActive_Then_NoMarksAreEmitted() [Description("Disabled ANSI output suppresses every mark regardless of mode: escape bytes must never reach non-ANSI writers.")] public async Task When_AnsiIsDisabled_Then_NoMarksAreEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -355,7 +347,7 @@ public void When_CommandLineContainsBackslashSemicolonAndControlChars_Then_Osc63 [Description("A hosted client advertising ANSI only through capability flags (no AnsiSupport override) still gets marks: the server console's redirection state must not suppress a capability the client explicitly advertised.")] public async Task When_HostedClientAdvertisesAnsiThroughCapabilities_Then_MarksAreEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -375,7 +367,7 @@ public async Task When_HostedClientAdvertisesAnsiThroughCapabilities_Then_MarksA [Description("A hosted client advertising ShellIntegrationMarks after the session started (Telnet TTYPE, control messages) gets marks from the next prompt cycle: enablement is re-evaluated per cycle, not frozen at session start.")] public async Task When_HostedSessionAdvertisesMarksMidSession_Then_MarksAppearOnNextPromptCycle() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -396,7 +388,7 @@ public async Task When_HostedSessionAdvertisesMarksMidSession_Then_MarksAppearOn [Description("Auto mode honors a mid-session identity downgrade: capability bits earned only by a previous identity inference are dropped when the client re-identifies as a markless terminal, so marks stop on the next prompt cycle instead of flowing forever.")] public async Task When_HostedClientDowngradesToDumbMidSession_Then_MarksStopOnNextPromptCycle() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -419,7 +411,7 @@ public async Task When_HostedClientDowngradesToDumbMidSession_Then_MarksStopOnNe [Description("An aborted or empty command reports D without an exit-code parameter, matching the FinalTerm 'command aborted' form.")] public async Task When_CommandEndsWithoutExitCode_Then_DIsEmittedWithoutParameter() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -439,7 +431,7 @@ public async Task When_CommandEndsWithoutExitCode_Then_DIsEmittedWithoutParamete [Description("The phase state machine makes a second command-end call a no-op so a command can never report two D marks.")] public async Task When_CommandEndIsCalledTwice_Then_OnlyOneDIsEmitted() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -483,7 +475,7 @@ public async Task When_GenericBackendIsActive_Then_CommandLineMarkIsSuppressed() [Description("Each prompt cycle records which gate decided the enablement, in the documented order, so a wrong on/off decision is triaged exactly instead of by symptom guessing. This pins the hosted Auto path: not advertised → advertised.")] public async Task When_HostedAutoResolves_Then_DecidingGateIsRecorded() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -505,7 +497,7 @@ public async Task When_HostedAutoResolves_Then_DecidingGateIsRecorded() [Description("The protocol-passthrough gate is recorded as the deciding reason when a passthrough scope is active, ahead of any mode or capability consideration.")] public async Task When_ProtocolPassthroughIsActive_Then_GateRecordsIt() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, @@ -523,7 +515,7 @@ public async Task When_ProtocolPassthroughIsActive_Then_GateRecordsIt() [Description("An app that never called UseTerminalIntegration records the not-configured gate, and Never mode records the mode gate — the two 'off by design' reasons stay distinguishable.")] public async Task When_IntegrationIsOffByDesign_Then_GateDistinguishesWhy() { - using var env = new EnvironmentVariableScope(NeutralTerminalEnvironment); + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var harness = new TerminalHarness(cols: 80, rows: 12); using var session = ReplSessionIO.SetSession( output: harness.Writer, diff --git a/src/Repl.Tests/Terminal/TerminalTestEnvironments.cs b/src/Repl.Tests/Terminal/TerminalTestEnvironments.cs new file mode 100644 index 0000000..0115ea6 --- /dev/null +++ b/src/Repl.Tests/Terminal/TerminalTestEnvironments.cs @@ -0,0 +1,20 @@ +namespace Repl.Tests.TerminalSupport; + +/// +/// Canonical environment shapes for terminal-detection tests, shared so each test class +/// doesn't maintain its own copy of the variable list. +/// +internal static class TerminalTestEnvironments +{ + /// + /// No terminal-identifying variables set: environment-based detection must see nothing. + /// + public static readonly (string Name, string? Value)[] Neutral = + [ + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null), + ]; +} From 5a8ebd3b14ff9edc69c988fb83e35421b51607f5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Jul 2026 12:17:18 -0400 Subject: [PATCH 28/32] docs: 633;E privacy note and the broad OCE-to-130 decoration Review-panel INFO findings: document that the VS Code 633;E report transmits the committed command line verbatim to the terminal (secrets typed as arguments included, matching VS Code shell-integration behavior for regular shells), and that any handler OperationCanceledException - not only Ctrl+C - decorates the command as interrupted (130). --- docs/terminal-shell-integration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index 2ffdb1a..32905be 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -29,9 +29,9 @@ In interactive REPL mode, each prompt cycle is delimited with the FinalTerm sema | Right before command execution | `C` (output start) | | After the command completes | `D;` (command end) | -Exit codes follow shell conventions: `0` for success, `1` for errors (failed results, unknown commands, validation failures), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form. +Exit codes follow shell conventions: `0` for success, `1` for errors (failed results, unknown commands, validation failures), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C. The interruption decoration is intentionally broad: a handler that throws `OperationCanceledException` for any other reason (its own timeout, a linked token) also reports `130`, because the loop cannot tell who requested the cancellation. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form. -The VS Code `E` mark reports the exact committed command line (with protocol escaping), which makes VS Code's command detection independent of what is visible on screen. +The VS Code `E` mark reports the exact committed command line (with protocol escaping), which makes VS Code's command detection independent of what is visible on screen. Note the privacy implication: whatever was typed at the prompt — including secrets passed as command arguments — is transmitted verbatim to the terminal, which may persist it for command detection and history. This mirrors what VS Code's own shell integration does for regular shells; if commands take secrets, prefer prompting for them interactively instead of passing them as arguments. CLI one-shot mode emits no marks: Repl does not own the surrounding shell prompt there, and fake prompt markers would corrupt the host shell's own command navigation. Nested interaction prompts (`IReplInteractionChannel` questions asked *during* a command) emit no marks either — they are not shell prompts. From 9204898b8bcb390d230a9222a9740fc6812d892f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 7 Jul 2026 15:58:22 -0400 Subject: [PATCH 29/32] fix: declare 633;P IsWindows/Prompt so VS Code anchors the first decoration Field report: in the VS Code integrated terminal on Windows, the gutter decoration of the FIRST interactive command landed on the banner line (later commands were fine). Root cause: VS Code anchors decorations at parse-time cursor positions, which ConPTY rewrites - worst right at process start. Real shells compensate by declaring properties VS Code uses to switch its command detection to ConPTY-tolerant heuristics; we declared none, so the position-trusting Unix path was used. The 633 backend now reports, before the first input-start mark (so the heuristics already cover the first command): - 633;P;IsWindows=True on a local Windows console only - hosted transports deliver bytes verbatim (no ConPTY), so they keep the position-trusting default; - 633;P;Prompt= (re-declared when scope navigation changes the prompt), letting the marker-adjustment heuristic recognize our custom prompt line, which matches none of VS Code''s built-in prompt patterns. Red-first tests: Prompt property precedes the first B in a vscode session, hosted sessions never report IsWindows, the OSC 133 backend reports no P sequence at all, and the local-Windows-only predicate. --- docs/terminal-shell-integration.md | 2 + src/Repl.Core/Session/InteractiveSession.cs | 5 +- .../Terminal/ShellIntegrationMarkEmitter.cs | 46 +++++++++++++++++- ...nteractiveSession_ShellIntegrationMarks.cs | 47 +++++++++++++++++++ .../Given_ShellIntegrationMarkEmitter.cs | 16 +++++++ 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index 32905be..e81692b 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -64,6 +64,8 @@ An input that only *looks* like it targets a passthrough route but does not actu The generic backend is OSC 133, understood by Windows Terminal, WezTerm, iTerm2, Ghostty, and others. When the VS Code integrated terminal is detected — `TERM_PROGRAM=vscode` for the local console, or a `vscode` terminal identity reported by the hosted session's client — Repl switches to the OSC 633 dialect and additionally reports the command line with `E`. Backend selection follows the same session boundary as `Auto`: the server's environment never picks the dialect for a remote client. +The 633 backend also declares two properties before the first prompt, like VS Code's own shell scripts do: `633;P;Prompt=` (the prompt text, re-declared when scope navigation changes it) and, on a local Windows console only, `633;P;IsWindows=True`. The latter switches VS Code's command detection to its ConPTY-compensating heuristics — without it, the gutter decoration of the first command can be misplaced because ConPTY rewrites the byte stream at process start. Hosted transports deliver bytes verbatim, so they never declare `IsWindows`. + ConEmu is deliberately excluded from `Auto`: it renders OSC 9;4 progress but not FinalTerm marks. ## Hosted sessions diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index c287d21..71a8db9 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -142,8 +142,9 @@ internal async ValueTask RunInteractiveSessionAsync( { var scopeTokens = cycle.ScopeTokens; var serviceProvider = cycle.ServiceProvider; - await cycle.Marks.WritePromptStartAsync().ConfigureAwait(false); - await ReplSessionIO.Output.WriteAsync(BuildPrompt(scopeTokens)).ConfigureAwait(false); + var promptText = BuildPrompt(scopeTokens); + await cycle.Marks.WritePromptStartAsync(promptText).ConfigureAwait(false); + await ReplSessionIO.Output.WriteAsync(promptText).ConfigureAwait(false); await ReplSessionIO.Output.WriteAsync(' ').ConfigureAwait(false); await cycle.Marks.WriteInputStartAsync().ConfigureAwait(false); var effectiveMode = app.Autocomplete.ResolveEffectiveAutocompleteMode(serviceProvider); diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index e1ddaa8..30dee9d 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -36,6 +36,8 @@ private enum Phase private bool _isVsCodeBackend; private MarkSet _marks = Osc133; private Phase _phase; + private bool _windowsPtyReported; + private string? _reportedPrompt; private readonly record struct MarkSet(string PromptStart, string InputStart, string OutputStart, string CommandEndNoCode); @@ -53,8 +55,12 @@ public static ShellIntegrationMarkEmitter Create( return new ShellIntegrationMarkEmitter(options, outputOptions); } - /// Prompt start (mark A): call before writing the prompt text. - public async ValueTask WritePromptStartAsync() + /// + /// Prompt start (mark A): call before writing the prompt text. Pass the prompt text + /// the loop is about to render so the VS Code backend can declare it (see + /// ). + /// + public async ValueTask WritePromptStartAsync(string? promptText = null) { // Hosted clients can advertise capabilities mid-session (Telnet TTYPE, // @@repl:* control messages), so enablement and backend are re-resolved at @@ -65,10 +71,46 @@ public async ValueTask WritePromptStartAsync() return; } + if (_isVsCodeBackend) + { + await ReportVsCodePromptContextAsync(promptText).ConfigureAwait(false); + } + _phase = Phase.Prompt; await ReplSessionIO.Output.WriteAsync(_marks.PromptStart).ConfigureAwait(false); } + // VS Code anchors command decorations at parse-time cursor positions, which ConPTY + // rewrites on a local Windows console (worst right at process start — the first + // command's decoration lands on the banner). Real shells compensate by declaring + // 633;P properties: IsWindows=True switches VS Code's command detection to its + // marker-adjusting heuristics, and Prompt= lets those heuristics recognize a + // custom prompt line. Both must precede the first input-start (B) so they already + // cover the very first command. + private async ValueTask ReportVsCodePromptContextAsync(string? promptText) + { + if (!_windowsPtyReported) + { + _windowsPtyReported = true; + if (ShouldReportWindowsConPty()) + { + await ReplSessionIO.Output.WriteAsync("\x1b]633;P;IsWindows=True\x07").ConfigureAwait(false); + } + } + + if (promptText is not null && !string.Equals(_reportedPrompt, promptText, StringComparison.Ordinal)) + { + _reportedPrompt = promptText; + await ReplSessionIO.Output.WriteAsync($"\x1b]633;P;Prompt={EscapeCommandLine(promptText)}{Bell}").ConfigureAwait(false); + } + } + + // Hosted transports deliver our bytes verbatim to the remote terminal (no ConPTY in + // the path), so they keep VS Code's position-trusting default; only a local console + // on Windows goes through ConPTY. + internal static bool ShouldReportWindowsConPty() => + !ReplSessionIO.IsSessionActive && OperatingSystem.IsWindows(); + /// Prompt end / input start (mark B): call after the prompt text, before reading the line. public async ValueTask WriteInputStartAsync() { diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index 9868c1a..f040cbf 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -179,6 +179,53 @@ public void When_VsCodeTerminalIsDetected_Then_CommandLineIsReportedWithOsc633E( raw.Should().NotContain("]133;"); } + [TestMethod] + [Description("The VS Code backend declares the prompt text via OSC 633;P;Prompt before the first input-start mark, so VS Code's marker-adjusting heuristics can recognize the custom prompt line (ConPTY rewrites make parse-time positions unreliable on Windows).")] + public void When_VsCodeBackendRuns_Then_PromptPropertyIsReportedBeforeInputStart() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r", terminalIdentity: "vscode"); + + raw.Should().Contain("]633;P;Prompt=>", because: "the interactive prompt ('>') must be declared to the terminal"); + raw.IndexOf("]633;P;Prompt=", StringComparison.Ordinal).Should().BeLessThan( + raw.IndexOf("]633;B", StringComparison.Ordinal), + because: "properties must precede the first input-start so the heuristics already apply to the first command"); + } + + [TestMethod] + [Description("Hosted sessions never report 633;P;IsWindows: the transport delivers bytes verbatim to the remote terminal, so VS Code's position-trusting default is correct there — ConPTY compensation is a local-console concern.")] + public void When_HostedVsCodeSessionRuns_Then_IsWindowsPropertyIsNotReported() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r", terminalIdentity: "vscode"); + + raw.Should().NotContain("]633;P;IsWindows"); + } + + [TestMethod] + [Description("The generic OSC 133 backend reports no P properties: the property sequence is a VS Code 633-dialect concept and would be garbage on FinalTerm-only terminals.")] + public void When_Osc133BackendRuns_Then_NoPropertySequenceIsReported() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r", terminalIdentity: "Windows Terminal"); + + raw.Should().Contain("]133;A", because: "sanity: the generic backend is active"); + raw.Should().NotContain(";P;Prompt"); + raw.Should().NotContain(";P;IsWindows"); + } + [TestMethod] [Description("A failed completion ambient command (complete without --target) reports exit code 1 in the command-end mark instead of decorating the failure as success.")] public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() diff --git a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs index b11d277..669b7e7 100644 --- a/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -531,6 +531,22 @@ public async Task When_IntegrationIsOffByDesign_Then_GateDistinguishesWhy() neverMode.LastGate.Should().Be(ShellIntegrationGate.ModeNever); } + [TestMethod] + [Description("IsWindows=True is reported only for a local console on Windows, where ConPTY sits between the app and the terminal: a hosted session keeps VS Code's position-trusting default, and non-Windows hosts have no ConPTY to compensate for.")] + public void When_CheckingConPtyReporting_Then_OnlyLocalWindowsConsoleQualifies() + { + bool insideSession; + using (ReplSessionIO.SetSession(new StringWriter(), TextReader.Null)) + { + insideSession = ShellIntegrationMarkEmitter.ShouldReportWindowsConPty(); + } + + var outsideSession = ShellIntegrationMarkEmitter.ShouldReportWindowsConPty(); + + insideSession.Should().BeFalse(because: "hosted transports deliver bytes verbatim, without ConPTY in the path"); + outsideSession.Should().Be(OperatingSystem.IsWindows(), because: "a local console goes through ConPTY exactly when the host OS is Windows"); + } + private static ShellIntegrationMarkEmitter CreateEmitter(ShellIntegrationMode mode) => ShellIntegrationMarkEmitter.Create( new TerminalIntegrationOptions { ShellIntegration = mode }, From 75a2e09b956fa0c61806cf845023a851a03ccab2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 7 Jul 2026 16:11:04 -0400 Subject: [PATCH 30/32] feat: expose the shell-integration autodetection through IReplSessionInfo A sample (or any handler) can now show which mode the terminal-integration layer negotiated: IReplSessionInfo.ShellIntegrationStatus returns "OSC 133", "OSC 633 (VS Code)", or "off ()" naming the deciding gate - informational strings, format not contractual. Implemented as a default interface member (source-compatible for external implementors) backed by a session-scoped ambient slot: the interactive loop opens it at loop scope so the async-local flows into handlers, and the mark emitter updates it at each per-cycle re-resolution. The spectre sample gains a `terminal` command rendering the detection (status, identity, capabilities, ANSI, window size) as a Spectre table - run it under Windows Terminal vs the VS Code integrated terminal to see the dialect switch. Red-first tests: a handler observes "OSC 633 (VS Code)" in a vscode session, and "off (NotConfigured)" without UseTerminalIntegration. --- samples/07-spectre/Program.cs | 22 ++++++++++- samples/07-spectre/README.md | 8 ++++ src/Repl.Core/IReplSessionInfo.cs | 10 +++++ src/Repl.Core/Session/InteractiveSession.cs | 7 +++- src/Repl.Core/Session/LiveSessionInfo.cs | 2 + .../Terminal/ShellIntegrationMarkEmitter.cs | 32 +++++++++++++-- .../Terminal/ShellIntegrationStatusAmbient.cs | 32 +++++++++++++++ ...nteractiveSession_ShellIntegrationMarks.cs | 39 +++++++++++++++++++ 8 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 src/Repl.Core/Terminal/ShellIntegrationStatusAmbient.cs diff --git a/samples/07-spectre/Program.cs b/samples/07-spectre/Program.cs index aa6be4a..2900b24 100644 --- a/samples/07-spectre/Program.cs +++ b/samples/07-spectre/Program.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Repl; +using Repl.Terminal; var app = ReplApp.Create(services => { @@ -14,8 +15,9 @@ { console.Write(new FigletText("Spectre").Color(Color.Blue)); console.MarkupLine(" [grey]Commands:[/] tour, list, activity, detail, chart, tree, json, path,"); - console.MarkupLine(" calendar, figlet, status, progress, add, configure, login"); + console.MarkupLine(" calendar, figlet, status, progress, add, configure, terminal, login"); }) + .UseTerminalIntegration() .UseDefaultInteractive() .UseCliProfile() .UseSpectreConsole(); @@ -411,6 +413,24 @@ await console.Progress() return Results.Success($"Enabled {selected.Count} feature(s)."); }); +// ────────────────────────────────────────────────────────────── +// terminal — Shell-integration autodetection status +// ────────────────────────────────────────────────────────────── +app.Map("terminal", + [Description("Show what the terminal-integration layer detected for this session")] + (IAnsiConsole console, IReplSessionInfo session) => + { + var table = new Table().Border(TableBorder.Rounded).BorderColor(Color.Blue) + .AddColumn("Property").AddColumn("Detected"); + table.AddRow("Shell integration", session.ShellIntegrationStatus ?? "no prompt cycle yet (CLI one-shot?)"); + table.AddRow("Terminal identity", session.TerminalIdentity ?? "unknown"); + table.AddRow("Capabilities", session.TerminalCapabilities.ToString()); + table.AddRow("ANSI", session.AnsiSupported ? "yes" : "no"); + table.AddRow("Window size", session.WindowSize is { } size ? $"{size.Width}x{size.Height}" : "unknown"); + console.Write(table); + return Results.Success("Terminal detection displayed."); + }); + // ────────────────────────────────────────────────────────────── // login — Secret input // ────────────────────────────────────────────────────────────── diff --git a/samples/07-spectre/README.md b/samples/07-spectre/README.md index 09dcc5d..99c5f4f 100644 --- a/samples/07-spectre/README.md +++ b/samples/07-spectre/README.md @@ -100,6 +100,14 @@ no Spectre-specific code in the handler. Uses `AskMultiChoiceAsync` which renders as a Spectre `MultiSelectionPrompt` with checkbox-style selection. +### `terminal` — Shell-integration autodetection + +Shows what the terminal-integration layer detected for the current session via +`IReplSessionInfo`: whether shell-integration marks are active and which dialect was +negotiated (`OSC 133`, `OSC 633 (VS Code)`, or `off ()` naming the gate that +disabled them), plus the reported identity, capabilities, and window size. Run it from +Windows Terminal and from the VS Code integrated terminal to see the detection change. + ### `login` — Secret input Uses `AskSecretAsync` which renders as a Spectre `TextPrompt` with masked input. diff --git a/src/Repl.Core/IReplSessionInfo.cs b/src/Repl.Core/IReplSessionInfo.cs index a9b4769..6efe4d4 100644 --- a/src/Repl.Core/IReplSessionInfo.cs +++ b/src/Repl.Core/IReplSessionInfo.cs @@ -36,4 +36,14 @@ public interface IReplSessionInfo /// Gets the terminal identity reported by the client (e.g. "xterm-256color"), or null when unknown. /// string? TerminalIdentity { get; } + + /// + /// Gets a human-readable snapshot of the shell-integration mark autodetection for the + /// current interactive prompt cycle: the active protocol ("OSC 133", + /// "OSC 633 (VS Code)") or "off (<gate>)" naming the gate that + /// disabled emission. null when no interactive prompt cycle has run in this + /// scope (for example CLI one-shot execution). Informational only — the string format + /// is not a stable contract. + /// + string? ShellIntegrationStatus => null; } diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 71a8db9..9d177ac 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -59,7 +59,12 @@ internal async ValueTask RunInteractiveSessionAsync( using var runtimeStateScope = app.PushRuntimeState(serviceProvider, isInteractiveSession: true); using var cancelHandler = new CancelKeyHandler(); var cycle = new PromptCycleContext( - Marks: ShellIntegrationMarkEmitter.Create(app.OptionsSnapshot.TerminalIntegration, app.OptionsSnapshot.Output), + Marks: ShellIntegrationMarkEmitter.Create( + app.OptionsSnapshot.TerminalIntegration, + app.OptionsSnapshot.Output, + // Opened at loop scope so the async-local flows into command handlers, + // where IReplSessionInfo.ShellIntegrationStatus reads it. + ShellIntegrationStatusAmbient.Open()), ScopeTokens: initialScopeTokens.ToList(), HistoryProvider: serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider, ServiceProvider: serviceProvider, diff --git a/src/Repl.Core/Session/LiveSessionInfo.cs b/src/Repl.Core/Session/LiveSessionInfo.cs index f80af74..c299d4a 100644 --- a/src/Repl.Core/Session/LiveSessionInfo.cs +++ b/src/Repl.Core/Session/LiveSessionInfo.cs @@ -17,4 +17,6 @@ internal sealed class LiveSessionInfo : IReplSessionInfo public TerminalCapabilities TerminalCapabilities => ReplSessionIO.TerminalCapabilities; public string? TerminalIdentity => ReplSessionIO.TerminalIdentity; + + public string? ShellIntegrationStatus => ShellIntegrationStatusAmbient.Current; } diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index 30dee9d..f927a23 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -38,21 +38,29 @@ private enum Phase private Phase _phase; private bool _windowsPtyReported; private string? _reportedPrompt; + private readonly ShellIntegrationStatusAmbient.Slot? _statusSlot; + private ShellIntegrationGate? _publishedGate; + private bool _publishedVsCodeBackend; private readonly record struct MarkSet(string PromptStart, string InputStart, string OutputStart, string CommandEndNoCode); - private ShellIntegrationMarkEmitter(TerminalIntegrationOptions? options, OutputOptions outputOptions) + private ShellIntegrationMarkEmitter( + TerminalIntegrationOptions? options, + OutputOptions outputOptions, + ShellIntegrationStatusAmbient.Slot? statusSlot) { _options = options; _outputOptions = outputOptions; + _statusSlot = statusSlot; } public static ShellIntegrationMarkEmitter Create( TerminalIntegrationOptions? options, - OutputOptions outputOptions) + OutputOptions outputOptions, + ShellIntegrationStatusAmbient.Slot? statusSlot = null) { ArgumentNullException.ThrowIfNull(outputOptions); - return new ShellIntegrationMarkEmitter(options, outputOptions); + return new ShellIntegrationMarkEmitter(options, outputOptions, statusSlot); } /// @@ -239,6 +247,24 @@ private void RefreshCycleConfiguration() _enabled = LastGate == ShellIntegrationGate.Enabled; _isVsCodeBackend = _enabled && IsVsCodeBackend(); _marks = _isVsCodeBackend ? Osc633 : Osc133; + PublishStatus(); + } + + // Publishes the human-readable detection outcome for IReplSessionInfo consumers + // (debug/sample commands); rebuilt only when the decision actually changed. + private void PublishStatus() + { + if (_statusSlot is not { } slot + || (_publishedGate == LastGate && _publishedVsCodeBackend == _isVsCodeBackend)) + { + return; + } + + _publishedGate = LastGate; + _publishedVsCodeBackend = _isVsCodeBackend; + slot.Status = _enabled + ? (_isVsCodeBackend ? "OSC 633 (VS Code)" : "OSC 133") + : $"off ({LastGate})"; } // Gates are evaluated in ShellIntegrationGate member order; the first failing gate diff --git a/src/Repl.Core/Terminal/ShellIntegrationStatusAmbient.cs b/src/Repl.Core/Terminal/ShellIntegrationStatusAmbient.cs new file mode 100644 index 0000000..b1b92c7 --- /dev/null +++ b/src/Repl.Core/Terminal/ShellIntegrationStatusAmbient.cs @@ -0,0 +1,32 @@ +namespace Repl; + +/// +/// Ambient, session-scoped publication of the shell-integration autodetection outcome. +/// The interactive loop opens a slot at loop scope — so the async-local flows into +/// command handlers — and the mark emitter updates the slot at each per-cycle +/// re-resolution. reads it. +/// +internal static class ShellIntegrationStatusAmbient +{ + /// + /// Mutable holder shared between the loop scope and its descendants: the async-local + /// captures the slot reference once, and the emitter mutates its content from child + /// contexts (an async-local value set inside a child would not flow back up). + /// + internal sealed class Slot + { + public string? Status { get; set; } + } + + private static readonly AsyncLocal s_current = new(); + + public static string? Current => s_current.Value?.Status; + + /// Opens a fresh slot for the current async scope and returns it for the emitter to update. + public static Slot Open() + { + var slot = new Slot(); + s_current.Value = slot; + return slot; + } +} diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index f040cbf..a92e911 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -226,6 +226,45 @@ public void When_Osc133BackendRuns_Then_NoPropertySequenceIsReported() raw.Should().NotContain(";P;IsWindows"); } + [TestMethod] + [Description("Handlers can read the shell-integration autodetection outcome through IReplSessionInfo.ShellIntegrationStatus: the active protocol when marks are on ('OSC 633 (VS Code)'), so a debug command can show which dialect the terminal negotiated.")] + public void When_HandlerReadsSessionInfo_Then_ShellIntegrationStatusExposesTheDetection() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + string? observed = null; + sut.Map("probe", (IReplSessionInfo session) => + { + observed = session.ShellIntegrationStatus; + return "ok"; + }); + var harness = new TerminalHarness(cols: 80, rows: 12); + + _ = RunInteractiveSession(harness, sut, "probe\rexit\r", terminalIdentity: "vscode"); + + observed.Should().Be("OSC 633 (VS Code)"); + } + + [TestMethod] + [Description("When marks are off, ShellIntegrationStatus names the deciding gate, so a sample or debug command can explain why nothing is emitted instead of guessing from symptoms.")] + public void When_IntegrationNotConfigured_Then_ShellIntegrationStatusNamesTheGate() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = ReplApp.Create().UseDefaultInteractive(); + sut.Options(options => options.Output.AnsiMode = AnsiMode.Always); + string? observed = null; + sut.Map("probe", (IReplSessionInfo session) => + { + observed = session.ShellIntegrationStatus; + return "ok"; + }); + var harness = new TerminalHarness(cols: 80, rows: 12); + + _ = RunInteractiveSession(harness, sut, "probe\rexit\r"); + + observed.Should().Be("off (NotConfigured)"); + } + [TestMethod] [Description("A failed completion ambient command (complete without --target) reports exit code 1 in the command-end mark instead of decorating the failure as success.")] public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() From fdd5f4854adbdcf76048b20d444d9815f2c9cd10 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 7 Jul 2026 16:33:27 -0400 Subject: [PATCH 31/32] fix: open the VS Code session with a lone D to close the outer command Field retest: with IsWindows/Prompt declared, the first gutter decoration still landed on the banner. VS Code source shows why, deterministically: when the app is launched from an integrated shell, that shell''s command (this process) is still open, and handlePromptStart anchors our first prompt by CLONING the last command''s end marker - which VS Code just repositioned to executedMarker+1, i.e. the first banner line. The Windows command-start heuristic then scans only 10 lines down for the prompt; a figlet banner is taller, so the adjustment gives up and the decoration sticks to the banner. The 633 backend now opens the session (once, before the very first A) with a lone command-end in the aborted form - the outer command''s exit code is unknowable from inside it. That closes the outer command at the true cursor position, so the first prompt-start anchors on the prompt line and the 10-line scan finds the prompt immediately. Scoped to the VS Code dialect: the stale-anchor behavior is specific to its command detection, and a leading D would skew FinalTerm mark counts. Red-first: the opener precedes the first 633;A (aborted form, no exit code), and the 133 backend emits no D before its first A. --- docs/terminal-shell-integration.md | 2 ++ .../Terminal/ShellIntegrationMarkEmitter.cs | 14 ++++++++ ...nteractiveSession_ShellIntegrationMarks.cs | 35 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index e81692b..1fa881c 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -66,6 +66,8 @@ The generic backend is OSC 133, understood by Windows Terminal, WezTerm, iTerm2, The 633 backend also declares two properties before the first prompt, like VS Code's own shell scripts do: `633;P;Prompt=` (the prompt text, re-declared when scope navigation changes it) and, on a local Windows console only, `633;P;IsWindows=True`. The latter switches VS Code's command detection to its ConPTY-compensating heuristics — without it, the gutter decoration of the first command can be misplaced because ConPTY rewrites the byte stream at process start. Hosted transports deliver bytes verbatim, so they never declare `IsWindows`. +Because a Repl app is usually *launched from* an integrated shell, that shell's own command (the app process) is still open from the terminal's point of view when the first prompt renders, and VS Code would anchor the first prompt at that command's stale end position — the first gutter decoration then lands on the app banner. The 633 backend therefore opens the session with a lone `D` (no exit code, the "aborted" form: the outer command's exit code is unknowable from inside it) before the very first `A`, closing the outer command at the true cursor position. This is a nested-shell handshake regular shells don't need; it is harmless when nothing was open. + ConEmu is deliberately excluded from `Auto`: it renders OSC 9;4 progress but not FinalTerm marks. ## Hosted sessions diff --git a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs index f927a23..6c2b9fa 100644 --- a/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -38,6 +38,7 @@ private enum Phase private Phase _phase; private bool _windowsPtyReported; private string? _reportedPrompt; + private bool _sessionOpened; private readonly ShellIntegrationStatusAmbient.Slot? _statusSlot; private ShellIntegrationGate? _publishedGate; private bool _publishedVsCodeBackend; @@ -81,6 +82,19 @@ public async ValueTask WritePromptStartAsync(string? promptText = null) if (_isVsCodeBackend) { + // Nested-terminal handshake, once per session: when this app was launched from + // an integrated shell, that shell's command (this very process) is still open + // from VS Code's point of view, and handlePromptStart would anchor our first + // prompt at that command's stale end position — the first gutter decoration + // then lands on the banner. A lone command-end (aborted form: the outer exit + // code is unknowable) closes it at the true cursor position first. Real shells + // don't need this because they are not nested; harmless when nothing was open. + if (!_sessionOpened) + { + _sessionOpened = true; + await ReplSessionIO.Output.WriteAsync(_marks.CommandEndNoCode).ConfigureAwait(false); + } + await ReportVsCodePromptContextAsync(promptText).ConfigureAwait(false); } diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index a92e911..bf2c3f8 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -196,6 +196,41 @@ public void When_VsCodeBackendRuns_Then_PromptPropertyIsReportedBeforeInputStart because: "properties must precede the first input-start so the heuristics already apply to the first command"); } + [TestMethod] + [Description("The VS Code backend opens the session with a lone command-end (no exit code) before the first prompt-start: when the app was launched from an integrated shell, that shell's command is still open and VS Code would anchor our first prompt at its stale end position (the banner). The opener closes it at the true cursor position.")] + public void When_VsCodeBackendRuns_Then_SessionOpensWithALoneCommandEndBeforeTheFirstPromptStart() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r", terminalIdentity: "vscode"); + + var firstCommandEnd = raw.IndexOf("]633;D", StringComparison.Ordinal); + var firstPromptStart = raw.IndexOf("]633;A", StringComparison.Ordinal); + firstCommandEnd.Should().BeGreaterThanOrEqualTo(0); + firstCommandEnd.Should().BeLessThan(firstPromptStart, because: "the opener must close the outer shell's command before our first prompt anchors"); + MarkPayloadAt(raw, firstCommandEnd).Should().NotContain("D;", because: "the opener uses the aborted form — we cannot know the outer command's exit code"); + } + + [TestMethod] + [Description("The generic OSC 133 backend does not emit the session opener: the stale-anchor behavior it compensates for is specific to VS Code's command detection, and a leading D would break the mark-count expectations of FinalTerm terminals.")] + public void When_Osc133BackendRuns_Then_NoCommandEndPrecedesTheFirstPromptStart() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(ShellIntegrationMode.Auto); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "ping\rexit\r", terminalIdentity: "Windows Terminal"); + + var firstCommandEnd = raw.IndexOf("]133;D", StringComparison.Ordinal); + var firstPromptStart = raw.IndexOf("]133;A", StringComparison.Ordinal); + firstPromptStart.Should().BeGreaterThanOrEqualTo(0); + firstCommandEnd.Should().BeGreaterThan(firstPromptStart, because: "the first D must be the first command's own end, not a session opener"); + } + [TestMethod] [Description("Hosted sessions never report 633;P;IsWindows: the transport delivers bytes verbatim to the remote terminal, so VS Code's position-trusting default is correct there — ConPTY compensation is a local-console concern.")] public void When_HostedVsCodeSessionRuns_Then_IsWindowsPropertyIsNotReported() From 58f102ab93273acd872ced1c9545734c0a0d44c7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 7 Jul 2026 16:41:27 -0400 Subject: [PATCH 32/32] docs: Windows Terminal needs explicit settings to surface the marks Field report: marks emitted under Windows Terminal but no scrollbar pips and no command navigation. Unlike VS Code, WT (stable since 1.21) only exposes mark features through configuration: showMarksOnScrollbar on the profile, and scrollToMark actions bound to keys. Added to the troubleshooting table. --- docs/terminal-shell-integration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index 1fa881c..3f894b1 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -80,6 +80,7 @@ The enablement decision emits no runtime diagnostics, but it is deterministic: t | Symptom | Gate to check | |---|---| +| Marks emitted but nothing visible in Windows Terminal | Terminal-side setup: unlike VS Code, Windows Terminal (≥ 1.21) exposes mark features only through settings — `"showMarksOnScrollbar": true` on the profile for scrollbar pips, and `scrollToMark` actions bound to keys (e.g. Ctrl+Up/Down) for command navigation; neither is on by default. | | Raw `]133;…` / `]633;…` text on screen | The terminal does not render marks. Set `ShellIntegrationMode.Never`, or `NO_COLOR=1` as an end-user escape hatch. | | No marks at all (expected some) | `ShellIntegration` mode (`Never`?); then `UseTerminalIntegration` actually called?; then the ANSI gate (`NO_COLOR`, `TERM=dumb`, `AnsiMode.Never`, redirected output with no hosted session). | | No marks under Auto specifically | Detection: locally `WT_SESSION`/`TERM_PROGRAM`; under tmux/screen Auto stays off; for a hosted client, the advertised `ShellIntegrationMarks` capability. |