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..32905be --- /dev/null +++ b/docs/terminal-shell-integration.md @@ -0,0 +1,90 @@ +# 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. 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. 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. + +## Modes + +`ShellIntegrationMode` mirrors the existing `AdvancedProgressMode` semantics: + +- `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. + +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). + +## 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. + +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). + +## Troubleshooting + +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 | +|---|---| +| 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 +- [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 diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index 590d8ff..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; } @@ -168,7 +174,7 @@ private bool ShouldEmitAdvancedProgress() { AdvancedProgressMode.Always => true, AdvancedProgressMode.Never => false, - _ => SessionAdvertisesAdvancedProgress() || IsKnownAdvancedProgressTerminal(), + _ => SessionAdvertisesAdvancedProgress() || TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal(), }; } @@ -176,34 +182,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/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 9d5ba98..c53ab93 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) @@ -314,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; } @@ -340,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/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/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/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 d822b13..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,67 +58,99 @@ 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; await app.ShellCompletionRuntimeInstance.HandleStartupAsync(serviceProvider, cancellationToken).ConfigureAwait(false); while (true) { cancellationToken.ThrowIfCancellationRequested(); - var readResult = await ReadInteractiveInputAsync( - scopeTokens, - historyProvider, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (readResult.Escaped) + try { - await ReplSessionIO.Output.WriteLineAsync().ConfigureAwait(false); - continue; // Esc at bare prompt → fresh line. + var (exit, updatedHistory) = await RunPromptCycleAsync(cycle, lastHistoryEntry, cancellationToken) + .ConfigureAwait(false); + lastHistoryEntry = updatedHistory; + if (exit) + { + return 0; + } } - - var line = readResult.Line; - if (line is null) + catch { - 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(cycle.Marks, exitCode: null).ConfigureAwait(false); + throw; } + } + } - var inputTokens = TokenizeInteractiveInput(line); - if (inputTokens.Count == 0) - { - 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( + PromptCycleContext cycle, + string? lastHistoryEntry, + CancellationToken cancellationToken) + { + var marks = cycle.Marks; + var readResult = await ReadInteractiveInputAsync(cycle, 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 DispatchInteractiveCommandAsync( - 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( + cycle.HistoryProvider, + lastHistoryEntry, + line, + cancellationToken) + .ConfigureAwait(false); + + var outcome = await ExecuteCommittedInputAsync(cycle, line, inputTokens, cancellationToken) + .ConfigureAwait(false); + return (outcome == AmbientCommandOutcome.Exit, lastHistoryEntry); } private async ValueTask ReadInteractiveInputAsync( - IReadOnlyList scopeTokens, - IHistoryProvider? historyProvider, - IServiceProvider serviceProvider, + PromptCycleContext cycle, CancellationToken cancellationToken) { + 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 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), @@ -129,70 +183,297 @@ internal async ValueTask RunInteractiveSessionAsync( return line; } - private async ValueTask DispatchInteractiveCommandAsync( - List inputTokens, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, + private async ValueTask<(AmbientCommandOutcome Outcome, int ExitCode)> DispatchInteractiveCommandAsync( + CommittedResolution resolution, + IReadOnlyList inputTokens, + PromptCycleContext cycle, CancellationToken cancellationToken) { - var ambientOutcome = await TryHandleAmbientCommandAsync( - inputTokens, - scopeTokens, - serviceProvider, - isInteractiveSession: true, - cancellationToken) - .ConfigureAwait(false); - if (ambientOutcome is AmbientCommandOutcome.Exit - or AmbientCommandOutcome.Handled - or AmbientCommandOutcome.HandledError) + if (resolution.Kind == CommittedKind.Ambient) { - return ambientOutcome; + var ambientOutcome = await TryHandleAmbientCommandAsync( + inputTokens, + cycle.ScopeTokens, + cycle.ServiceProvider, + isInteractiveSession: true, + cancellationToken) + .ConfigureAwait(false); + return (ambientOutcome, ambientOutcome == AmbientCommandOutcome.HandledError ? 1 : 0); } - 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) + if (resolution.Kind == CommittedKind.Ambiguous) { - var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(prefixResolution); - _ = await app.RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken, isInteractive: true) + // 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) .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; + return (AmbientCommandOutcome.Handled, 1); } - var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - 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, cycle, cancellationToken) .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; + return (AmbientCommandOutcome.Handled, exitCode); } - private async ValueTask ExecuteWithCancellationAsync( - GlobalInvocationOptions resolvedOptions, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, + private async ValueTask ExecuteWithCancellationAsync( + CommittedResolution resolution, + 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 { - await ExecuteInteractiveInputAsync(resolvedOptions, scopeTokens, serviceProvider, commandCts.Token) + return await ExecuteInteractiveInputAsync(resolution, cycle, 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. 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 { - cancelHandler.SetCommandCts(cts: null); - SetCommandTokenOnChannel(serviceProvider, default); + cycle.CancelHandler.SetCommandCts(cts: null); + SetCommandTokenOnChannel(cycle.ServiceProvider, default); + } + } + + /// + /// 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( + PromptCycleContext cycle, + string line, + IReadOnlyList inputTokens, + CancellationToken cancellationToken) + { + 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 + // written around the prompt (before the command was known); the cycle is + // abandoned silently and the next prompt-start implicitly closes it. + var isProtocolPassthrough = resolution.IsProtocolPassthrough; + if (!isProtocolPassthrough) + { + await marks.WriteCommandLineAsync(line).ConfigureAwait(false); + await marks.WriteOutputStartAsync().ConfigureAwait(false); + } + + AmbientCommandOutcome outcome; + int exitCode; + try + { + (outcome, exitCode) = await DispatchInteractiveCommandAsync(resolution, inputTokens, cycle, cancellationToken) + .ConfigureAwait(false); + } + catch when (isProtocolPassthrough) + { + 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. 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; + } + + 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 + { + await marks.WriteCommandEndAsync(exitCode).ConfigureAwait(false); + } + + 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, + } + + /// + /// 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 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; + } + + /// + /// 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 . + /// + 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 CommittedResolution.Ambient(); + } + + 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 + // 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 CommittedResolution.Ambiguous(globalOptions, graph, prefixResolution); + } + + var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + if (resolvedOptions.HelpRequested) + { + return CommittedResolution.Help(resolvedOptions, graph, prefixResolution); + } + + var routes = app.ResolveWithDiagnostics(resolvedOptions.RemainingTokens, graph.Routes); + return CommittedResolution.Routed(resolvedOptions, graph, prefixResolution, routes); + } + + /// + /// 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(IReadOnlyList inputTokens) + { + if (inputTokens.Count == 0) + { + return false; + } + + var token = inputTokens[0]; + return CoreReplApp.IsHelpToken(token) + || (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); } private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, CancellationToken ct) @@ -203,27 +484,55 @@ private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, C } } - private async ValueTask ExecuteInteractiveInputAsync( - GlobalInvocationOptions globalOptions, - List scopeTokens, - IServiceProvider serviceProvider, + private async ValueTask ExecuteInteractiveInputAsync( + CommittedResolution committed, + PromptCycleContext cycle, CancellationToken cancellationToken) { - var activeGraph = app.ResolveActiveRoutingGraph(); + var globalOptions = committed.Options; 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); + // 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; + var resolution = committed.Routes; var match = resolution.Match; if (match is not null) { - await app.ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); - return; + 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, cycle.ServiceProvider, 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, cycle, 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, + PromptCycleContext cycle, + CancellationToken cancellationToken) + { + var serviceProvider = cycle.ServiceProvider; var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, app.OptionsSnapshot.Parsing); if (contextMatch is not null) { @@ -237,18 +546,18 @@ private async ValueTask ExecuteInteractiveInputAsync( cancellationToken, isInteractive: true) .ConfigureAwait(false); - return; + 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)) { await app.InvokeBannerAsync(contextBanner, serviceProvider, cancellationToken).ConfigureAwait(false); } - return; + return 0; } var failure = app.CreateRouteResolutionFailureResult( @@ -261,6 +570,7 @@ private async ValueTask ExecuteInteractiveInputAsync( cancellationToken, isInteractive: true) .ConfigureAwait(false); + return 1; } [SuppressMessage( @@ -268,14 +578,16 @@ private async ValueTask ExecuteInteractiveInputAsync( "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, 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; } @@ -287,32 +599,32 @@ 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)) + 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)) { - _ = 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)) + if (string.Equals(token, AutocompleteAmbientToken, StringComparison.OrdinalIgnoreCase)) { return await HandleAutocompleteAmbientCommandAsync( inputTokens.Skip(1).ToArray(), @@ -321,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(), @@ -570,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/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.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/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 new file mode 100644 index 0000000..e1ddaa8 --- /dev/null +++ b/src/Repl.Core/Terminal/ShellIntegrationMarkEmitter.cs @@ -0,0 +1,277 @@ +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"; + + // 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 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"); + + private static readonly SearchValues EscapedCommandLineChars = CreateEscapedCommandLineChars(); + + private readonly TerminalIntegrationOptions? _options; + private readonly OutputOptions _outputOptions; + private bool _enabled; + private bool _isVsCodeBackend; + 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; + _outputOptions = outputOptions; + } + + public static ShellIntegrationMarkEmitter Create( + TerminalIntegrationOptions? options, + OutputOptions outputOptions) + { + ArgumentNullException.ThrowIfNull(outputOptions); + 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; + } + + _phase = Phase.Prompt; + await ReplSessionIO.Output.WriteAsync(_marks.PromptStart).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(_marks.InputStart).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(_marks.OutputStart).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 + /// 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 mark = exitCode is { } code + ? $"\x1b]{(_isVsCodeBackend ? "633" : "133")};D;{code.ToString(CultureInfo.InvariantCulture)}{Bell}" + : _marks.CommandEndNoCode; + await ReplSessionIO.Output.WriteAsync(mark).ConfigureAwait(false); + } + + /// + /// Escapes a command line for the OSC 633;E payload per the VS Code shell-integration + /// 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) + { + 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 (IsForbiddenControl(ch)) + { + builder.Append(@"\x").Append(hexDigits[(ch >> 4) & 0xF]).Append(hexDigits[ch & 0xF]); + } + else + { + builder.Append(ch); + } + } + + 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. 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'); + + /// + /// 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() + { + LastGate = ResolveGate(_options, _outputOptions); + _enabled = LastGate == ShellIntegrationGate.Enabled; + _isVsCodeBackend = _enabled && IsVsCodeBackend(); + _marks = _isVsCodeBackend ? Osc633 : Osc133; + } + + // 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) + { + if (options is null) + { + return ShellIntegrationGate.NotConfigured; + } + + if (ReplSessionIO.IsProtocolPassthrough) + { + return ShellIntegrationGate.ProtocolPassthrough; + } + + if (!TerminalAnsiCapability.IsAnsiCapableForTerminalSequences(outputOptions)) + { + return ShellIntegrationGate.AnsiUnsupported; + } + + if (Console.IsOutputRedirected && !ReplSessionIO.IsSessionActive) + { + return ShellIntegrationGate.OutputRedirected; + } + + // 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 => ShellIntegrationGate.Enabled, + ShellIntegrationMode.Never => ShellIntegrationGate.ModeNever, + _ when ReplSessionIO.IsSessionActive => SessionAdvertisesShellIntegration() + ? ShellIntegrationGate.Enabled + : ShellIntegrationGate.SessionNotAdvertising, + _ => TerminalEnvironmentClassifier.IsKnownShellIntegrationTerminal() + ? ShellIntegrationGate.Enabled + : ShellIntegrationGate.EnvironmentUnknown, + }; + } + + private static bool SessionAdvertisesShellIntegration() => + 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() => + ReplSessionIO.IsSessionActive + ? ReplSessionIO.TerminalIdentity?.Contains("vscode", StringComparison.OrdinalIgnoreCase) is true + : TerminalEnvironmentClassifier.IsVsCodeTerminal(); + + // 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() + { + // 2 punctuation + 33 (0x00–0x20) + 1 (DEL) + 32 (0x80–0x9f) = 68. + Span chars = stackalloc char[68]; + chars[0] = '\\'; + chars[1] = ';'; + 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[next++] = (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/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.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 new file mode 100644 index 0000000..6cdcb8a --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalEnvironmentClassifier.cs @@ -0,0 +1,50 @@ +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); + } + + 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.Core/Terminal/TerminalIntegrationOptions.cs b/src/Repl.Core/Terminal/TerminalIntegrationOptions.cs new file mode 100644 index 0000000..f82ebc9 --- /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. +/// +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; +} 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.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.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)); +} diff --git a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs index d05ffa9..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() @@ -123,29 +149,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 new file mode 100644 index 0000000..9868c1a --- /dev/null +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -0,0 +1,642 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_InteractiveSession_ShellIntegrationMarks +{ + + [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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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("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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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); + 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"); + } + + [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(TerminalTestEnvironments.Neutral); + 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); + 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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("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(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 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("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(TerminalTestEnvironments.Neutral); + 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("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(TerminalTestEnvironments.Neutral); + 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 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(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve --json\rexit\r"); + + 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] + [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(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Map("serve", (IReplIoContext _) => "protocol-payload").AsProtocolPassthrough(); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "serve\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 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("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(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + 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"); + + raw.Should().NotContain("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: "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(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Map("serve", (IReplIoContext _) => 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"); + 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + 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"); + 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] + [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(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Map("serve", (IReplIoContext _) => "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() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + 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"); + + 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] + [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(TerminalTestEnvironments.Neutral); + 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() + { + 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(TerminalTestEnvironments.Neutral); + 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"); + TerminalMarks.Count(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(TerminalTestEnvironments.Neutral); + 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"); + + 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(TerminalTestEnvironments.Neutral); + 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"); + } + + [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(TerminalTestEnvironments.Neutral); + 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() + .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, + 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(writer, TextReader.Null); + try + { + ReplSessionIO.KeyReader = keyReader; + ReplSessionIO.WindowSize = size; + ReplSessionIO.AnsiSupport = true; + ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; + if (terminalIdentity is not null) + { + ReplSessionIO.TerminalIdentity = terminalIdentity; + } + + try + { + _ = sut.Run([]); + } + catch (InvalidOperationException ex) when (swallowRunExceptions) + { + // Only the expected ambient-failure exception type is captured for + // assertion; any other type escapes as a genuine test failure. + return ex; + } + + return null; + } + finally + { + ReplSessionIO.KeyReader = previousReader; + } + } + + /// + /// 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 + { + '\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; + + // 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) + { + ThrowIfFragmentObserved(value); + inner.Write(value); + } + + public override Task WriteAsync(char value) + { + ThrowIfFragmentObserved(value.ToString()); + return inner.WriteAsync(value); + } + + public override Task WriteAsync(string? value) + { + ThrowIfFragmentObserved(value); + return inner.WriteAsync(value); + } + + public override Task WriteLineAsync(string? value) + { + ThrowIfFragmentObserved(value); + return inner.WriteLineAsync(value); + } + + private void ThrowIfFragmentObserved(string? value) + { + 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_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 new file mode 100644 index 0000000..b11d277 --- /dev/null +++ b/src/Repl.Tests/Given_ShellIntegrationMarkEmitter.cs @@ -0,0 +1,546 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ShellIntegrationMarkEmitter +{ + + [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(TerminalTestEnvironments.Neutral); + 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("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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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() + { + 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 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", "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 RunFullLifecycleAsync(emitter); + + harness.RawOutput.Should().NotContain("]133;"); + harness.RawOutput.Should().NotContain("]633;"); + } + + [TestMethod] + [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), + ("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); + 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(TerminalTestEnvironments.Neutral); + 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: 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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, 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("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"); + + // 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"); + // 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] + [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(TerminalTestEnvironments.Neutral); + 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() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + 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("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(TerminalTestEnvironments.Neutral); + 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() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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); + + TerminalMarks.Count(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;"); + } + + [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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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(TerminalTestEnvironments.Neutral); + 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 }, + 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); + } +} 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 new file mode 100644 index 0000000..f4fc69c --- /dev/null +++ b/src/Repl.Tests/Given_TerminalEnvironmentClassifier.cs @@ -0,0 +1,115 @@ +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(); + } + + [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(); + } +} 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); + } + } +} 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()); +} 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), + ]; +}