Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
da1ec92
refactor: extract shared terminal environment classifier
carldebilly Jul 5, 2026
66eadb1
feat: add ShellIntegrationMarks terminal capability
carldebilly Jul 5, 2026
7bec359
feat: add shell-integration mark emitter and UseTerminalIntegration
carldebilly Jul 5, 2026
0a2ea6f
feat: emit shell-integration marks around the interactive loop
carldebilly Jul 5, 2026
d489825
docs: document terminal shell integration
carldebilly Jul 5, 2026
f6bee0f
fix: keep server environment out of hosted mark decisions
carldebilly Jul 5, 2026
8c066a8
fix: close mark lifecycle on every dispatch path
carldebilly Jul 5, 2026
7370c10
fix: re-evaluate marks per cycle, escape spaces, honor --help
carldebilly Jul 5, 2026
a4d945a
fix: keep failure marks for passthrough errors, honor hosted ANSI caps
carldebilly Jul 5, 2026
b920964
fix: always abandon dispatched passthrough cycles, ambient-first clas…
carldebilly Jul 5, 2026
99bb337
fix: escape DEL and C1 controls in OSC 633;E payload
carldebilly Jul 5, 2026
6f9574d
refactor: resolve committed input once; harden mark-cleanup catch
carldebilly Jul 5, 2026
0e09bfa
refactor: cache mark strings, pin exact framing, dedupe test helpers
carldebilly Jul 5, 2026
6ef23ee
docs: clarify passthrough marks, add troubleshooting and disable paths
carldebilly Jul 5, 2026
cd06c8a
fix: bound C1 escaping, resolve prefixes on the captured graph before…
carldebilly Jul 6, 2026
ca132c4
fix: close the shell-integration cycle when the line read fails
carldebilly Jul 6, 2026
da19857
fix: apply parsed globals before resolving the committed route
carldebilly Jul 6, 2026
b39b863
test: narrow CaptureInteractiveRun to the expected exception type
carldebilly Jul 6, 2026
4459be9
fix: replace identity-inferred capabilities on terminal re-identifica…
carldebilly Jul 6, 2026
cc9a058
fix: run interactive passthrough dispatch under the shared protocol c…
carldebilly Jul 6, 2026
1668bd8
test: prove the interactive globals-before-route order with a cache-b…
carldebilly Jul 6, 2026
f3cf430
feat: name the deciding shell-integration gate for exact triage
carldebilly Jul 6, 2026
81bd744
refactor: make CommittedResolution kind/field pairing structural
carldebilly Jul 6, 2026
78117f2
fix: share the hosted-ANSI capability gate with advanced progress
carldebilly Jul 6, 2026
04eded1
test: pin the no-marks guarantee for CLI one-shot execution
carldebilly Jul 6, 2026
05a552a
refactor: prompt-cycle context, shared ambient tokens, tighter signat…
carldebilly Jul 6, 2026
2abc367
test: dedupe harness setup, harden fault injection, drop brittle coup…
carldebilly Jul 6, 2026
5a8ebd3
docs: 633;E privacy note and the broad OCE-to-130 decoration
carldebilly Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/interactive-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/terminal-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
90 changes: 90 additions & 0 deletions docs/terminal-shell-integration.md
Original file line number Diff line number Diff line change
@@ -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>` (command-line report) |
| Right before command execution | `C` (output start) |
| After the command completes | `D;<exit code>` (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 `<route> --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
34 changes: 8 additions & 26 deletions src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand All @@ -168,42 +174,18 @@ private bool ShouldEmitAdvancedProgress()
{
AdvancedProgressMode.Always => true,
AdvancedProgressMode.Never => false,
_ => SessionAdvertisesAdvancedProgress() || IsKnownAdvancedProgressTerminal(),
_ => SessionAdvertisesAdvancedProgress() || TerminalEnvironmentClassifier.IsKnownAdvancedProgressTerminal(),
};
}

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
Expand Down
41 changes: 23 additions & 18 deletions src/Repl.Core/CoreReplApp.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,20 +217,6 @@ private async ValueTask<int> 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)
Expand All @@ -256,12 +242,31 @@ private async ValueTask<int> ExecuteMatchedCommandAndMaybeEnterInteractiveAsync(
return exitCode;
}

private async ValueTask<int> ExecuteProtocolPassthroughCommandAsync(
/// <summary>
/// Executes a protocol-passthrough command under the single passthrough contract shared
/// by the CLI one-shot and interactive paths: the hosted-capability guard, the
/// <see cref="ReplSessionIO.PushProtocolPassthrough"/> scope handlers can observe, and —
/// outside hosted sessions — stdout/stderr isolation (framework output on stderr, the
/// handler payload alone on stdout).
/// </summary>
internal async ValueTask<int> 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)
Expand Down Expand Up @@ -314,7 +319,7 @@ private async ValueTask<int> 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;
}
Expand All @@ -340,11 +345,11 @@ private async ValueTask<int> 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);
Expand Down
2 changes: 1 addition & 1 deletion src/Repl.Core/CoreReplApp.Interactive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ private string[] GetDeepestContextScopePath(IReadOnlyList<string> matchedPathTok
Interactive.GetDeepestContextScopePath(matchedPathTokens);

private ValueTask<AmbientCommandOutcome> TryHandleAmbientCommandAsync(
List<string> inputTokens,
IReadOnlyList<string> inputTokens,
List<string> scopeTokens,
IServiceProvider serviceProvider,
bool isInteractiveSession,
Expand Down
3 changes: 3 additions & 0 deletions src/Repl.Core/CoreReplApp.Routing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ private string BuildBannerText() =>
internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList<string> tokens) =>
RoutingEng.ResolveUniquePrefixes(tokens);

internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList<string> tokens, ActiveRoutingGraph activeGraph) =>
RoutingEng.ResolveUniquePrefixes(tokens, activeGraph);

internal RouteDefinition[] ResolveDiscoverableRoutes(
IReadOnlyList<RouteDefinition> routes,
IReadOnlyList<ContextDefinition> contexts,
Expand Down
6 changes: 6 additions & 0 deletions src/Repl.Core/ReplOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,10 @@ public ReplOptions()
/// Gets shell completion setup options.
/// </summary>
public ShellCompletionOptions ShellCompletion { get; }

/// <summary>
/// Gets or sets terminal-integration options. Null (the default) keeps every
/// terminal-integration emitter disabled; set through <c>UseTerminalIntegration</c>.
/// </summary>
internal TerminalIntegrationOptions? TerminalIntegration { get; set; }
}
9 changes: 7 additions & 2 deletions src/Repl.Core/Routing/RoutingEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,14 @@ internal string BuildBannerText()
: $"{header}{Environment.NewLine}{description}";
}

internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList<string> tokens)
internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList<string> 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<string> tokens, ActiveRoutingGraph activeGraph)
{
var activeGraph = app.ResolveActiveRoutingGraph();
if (tokens.Count == 0)
{
return new PrefixResolutionResult(tokens: []);
Expand Down
Loading
Loading