From 06a164b41b12d3a44aa6a4e73120f04b17f304f2 Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 11 Jun 2026 15:14:47 -0600 Subject: [PATCH] fix: restore Application.AppModel after dispatch and lock in init contract Application.AppModel is process-wide static state. RunWithTerminalGuiAsync left it set to the dispatched command's app model (e.g. Inline), so later Terminal.Gui sessions in the same process - embedding scenarios, headless MarkdownRenderer rendering - inherited the wrong model. Save the previous value and restore it in a finally block (issue #26). Adds regression tests written first: - InputCommand_Inline_RestoresAppModelAfterDispatch and InputCommand_ThrowsCancellation_RestoresAppModel failed before the fix. - InputCommand_Inline_ReceivesInitializedApplication locks in the ICliCommand.RunAsync init contract for the inline path (issue #18, already fixed by the always-Init change in #23). Fixes #18 Fixes #26 Co-Authored-By: Claude Fable 5 --- src/Terminal.Gui.Cli/CliHost.cs | 18 ++++- .../AppModelDispatchTests.cs | 77 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index db07974..73194e1 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -257,12 +257,24 @@ private async Task RunWithTerminalGuiAsync (ICliCommand command, CancellationToken cancellationToken) { var useInline = command.Kind == CommandKind.Input && !runOptions.Fullscreen; + + // Application.AppModel is process-wide state; restore it after dispatch so later + // Terminal.Gui sessions in the same process (embedding, headless rendering) do not + // inherit this command's app model. + AppModel previousAppModel = Application.AppModel; Application.AppModel = useInline ? AppModel.Inline : AppModel.FullScreen; - using IApplication app = Application.Create (); - app.Init (); + try + { + using IApplication app = Application.Create (); + app.Init (); - return await command.RunAsync (app, runOptions.Initial, runOptions, cancellationToken); + return await command.RunAsync (app, runOptions.Initial, runOptions, cancellationToken); + } + finally + { + Application.AppModel = previousAppModel; + } } private void WriteRootFlag (ArgParser.RootFlag rootFlag, TextWriter stdout) diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/AppModelDispatchTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/AppModelDispatchTests.cs index 59d94de..0323ef8 100644 --- a/tests/Terminal.Gui.Cli.IntegrationTests/AppModelDispatchTests.cs +++ b/tests/Terminal.Gui.Cli.IntegrationTests/AppModelDispatchTests.cs @@ -5,10 +5,85 @@ namespace Terminal.Gui.Cli.IntegrationTests; /// /// Verifies that CliHost.RunWithTerminalGuiAsync sets IApplication.AppModel -/// correctly based on CommandKind and the --fullscreen option. +/// correctly based on CommandKind and the --fullscreen option, initializes the +/// application before dispatching (issue #18), and restores the process-wide +/// Application.AppModel after dispatch (issue #26). /// public sealed class AppModelDispatchTests { + [Fact] + public async Task InputCommand_Inline_ReceivesInitializedApplication () + { + // ICliCommand.RunAsync contract: commands run after the host has initialized + // Terminal.Gui, including on the inline input path (issue #18). + bool? initialized = null; + SpyInputCommand spy = new (app => initialized = app.Initialized); + + CliHost host = new (); + host.Registry.Register (spy); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + await host.RunAsync (["spy-input"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.True (initialized); + } + + [Fact] + public async Task InputCommand_Inline_RestoresAppModelAfterDispatch () + { + // Application.AppModel is process-wide; leaving it set to Inline after dispatch + // makes later Terminal.Gui sessions in the same process inherit it (issue #26). + AppModel before = Application.AppModel; + + try + { + Application.AppModel = AppModel.FullScreen; + SpyInputCommand spy = new (_ => { }); + + CliHost host = new (); + host.Registry.Register (spy); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + await host.RunAsync (["spy-input"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (AppModel.FullScreen, Application.AppModel); + } + finally + { + Application.AppModel = before; + } + } + + [Fact] + public async Task InputCommand_ThrowsCancellation_RestoresAppModel () + { + // The restore must also happen when the command throws (issue #26). + AppModel before = Application.AppModel; + + try + { + Application.AppModel = AppModel.FullScreen; + SpyInputCommand spy = new (_ => throw new OperationCanceledException ()); + + CliHost host = new (); + host.Registry.Register (spy); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (["spy-input"], TestContext.Current.CancellationToken, stdout, + stderr); + + Assert.Equal (ExitCodes.Cancelled, exitCode); + Assert.Equal (AppModel.FullScreen, Application.AppModel); + } + finally + { + Application.AppModel = before; + } + } + [Fact] public async Task InputCommand_WithoutFullscreen_SetsInlineAppModel () {