From 12b7764d9078da74fd4cae1d2b56b1a7b7c1ded1 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Fri, 9 Oct 2026 17:56:09 -0700 Subject: [PATCH 1/2] fix: stabilize cross-platform CI tests --- docs/CHANGELOG.md | 3 +++ src/MandoCode/Components/PromptInput.razor | 7 ++++--- tests/MandoCode.Tests/CommandOutputSinkTests.cs | 5 ++++- tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs | 2 ++ .../MandoCode.Tests/PlanCheckpointEnvelopeTests.cs | 5 +++-- tests/MandoCode.Tests/PlanCheckpointStoreTests.cs | 14 +++++++------- tests/MandoCode.Tests/TuiLayoutTests.cs | 2 ++ tests/MandoCode.Tests/WorkspaceRegistryTests.cs | 4 ++++ 8 files changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ee5e96..269485b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,9 @@ All notable changes to MandoCode will be documented in this file. ### Fixed +- Make clipboard-reader overrides work across platforms while retaining native terminal paste on Linux when only the Windows clipboard reader is available. +- Stabilize CI layout checks under forced ANSI output, use native paths in checkpoint tests, and restore the process working directory after testing bare `cd` commands. + - Complete pending menu/input requests when an agent closes, including model selection, directory selection, and plan approvals. - Release callbacks only when the disposing component still owns them, preventing an old component from clearing callbacks registered by its replacement. - Record diagnostic warnings for failed history serialization and context recap persistence. diff --git a/src/MandoCode/Components/PromptInput.razor b/src/MandoCode/Components/PromptInput.razor index fa826ee..31c39bd 100644 --- a/src/MandoCode/Components/PromptInput.razor +++ b/src/MandoCode/Components/PromptInput.razor @@ -109,13 +109,14 @@ private async Task PasteClipboardImage(bool explicitRequest) { if (Disabled || !ChatFeaturesEnabled || ModelPicker is not null || Pane is { Active: false }) return true; - if (!explicitRequest && !OperatingSystem.IsWindows()) return false; + var services = Pane?.Services ?? Services; + var reader = services.GetService(typeof(IClipboardImageReader)) as IClipboardImageReader ?? new WindowsClipboardImageReader(); + // Linux terminal paste remains native; an injected reader can provide clipboard support on any OS. + if (!explicitRequest && !OperatingSystem.IsWindows() && reader is WindowsClipboardImageReader) return false; if (_pastingImage) return true; _pastingImage = true; _clipboardError = false; _clipboardStatus = "Reading clipboard…"; StateHasChanged(); try { - var services = Pane?.Services ?? Services; - var reader = services.GetService(typeof(IClipboardImageReader)) as IClipboardImageReader ?? new WindowsClipboardImageReader(); var png = await reader.ReadPngAsync(_loadingStop.Token); if (_disposed) return true; if (png is null) { diff --git a/tests/MandoCode.Tests/CommandOutputSinkTests.cs b/tests/MandoCode.Tests/CommandOutputSinkTests.cs index e428c0d..b5600e8 100644 --- a/tests/MandoCode.Tests/CommandOutputSinkTests.cs +++ b/tests/MandoCode.Tests/CommandOutputSinkTests.cs @@ -9,6 +9,7 @@ namespace MandoCode.Tests; /// observational: it must see the full lifecycle, it must see output the model's capped copy /// drops, and it must never be able to affect the command it is watching. /// +[Collection("TUI console routing")] [Trait("Category", "Integration")] public class CommandOutputSinkTests : IDisposable { @@ -67,7 +68,9 @@ public async Task BareCdIsNotAnnounced() // command header on the display with no output and no completion under it. var sink = new RecordingSink(); - await Plugin(sink).ExecuteCommand("cd ."); + var originalDirectory = Directory.GetCurrentDirectory(); + try { await Plugin(sink).ExecuteCommand("cd ."); } + finally { Directory.SetCurrentDirectory(originalDirectory); } Assert.Equal(0, sink.Starts); Assert.Equal(0, sink.Finishes); diff --git a/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs b/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs index b8797d7..9e51e5f 100644 --- a/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs +++ b/tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs @@ -186,6 +186,8 @@ private static string RenderPlain(IRenderable renderable, int width = 100) ColorSystem = ColorSystemSupport.NoColors, Out = new AnsiConsoleOutput(writer), }); + // CI can force ANSI even when settings request plain output. Pin the test profile. + console.Profile.Capabilities.Ansi = false; console.Profile.Width = width; console.Write(renderable); return writer.ToString().Replace("\r\n", "\n"); diff --git a/tests/MandoCode.Tests/PlanCheckpointEnvelopeTests.cs b/tests/MandoCode.Tests/PlanCheckpointEnvelopeTests.cs index 93873c9..b34048e 100644 --- a/tests/MandoCode.Tests/PlanCheckpointEnvelopeTests.cs +++ b/tests/MandoCode.Tests/PlanCheckpointEnvelopeTests.cs @@ -98,8 +98,9 @@ public void DifferentDesktopAgentSession_IsRefused() [Fact] public void ProjectRootHash_IsStable_AndCaseInsensitive() { - var a = PlanCheckpointEnvelope.HashProjectRoot(@"C:\work\Api"); - var b = PlanCheckpointEnvelope.HashProjectRoot(@"c:\work\api\"); + var root = Path.Combine(Path.GetTempPath(), "work", "Api"); + var a = PlanCheckpointEnvelope.HashProjectRoot(root); + var b = PlanCheckpointEnvelope.HashProjectRoot(root.ToLowerInvariant() + Path.DirectorySeparatorChar); Assert.Equal(a, b); Assert.Equal(12, a.Length); diff --git a/tests/MandoCode.Tests/PlanCheckpointStoreTests.cs b/tests/MandoCode.Tests/PlanCheckpointStoreTests.cs index 72a9dc7..9f391b0 100644 --- a/tests/MandoCode.Tests/PlanCheckpointStoreTests.cs +++ b/tests/MandoCode.Tests/PlanCheckpointStoreTests.cs @@ -100,10 +100,10 @@ public void RebuiltPlanStartsPending() [Fact] public void PathIsStablePerProject_AndDistinguishesSameLeafNames() { - var a = PlanCheckpointStore.PathFor(@"C:\one\api"); - var b = PlanCheckpointStore.PathFor(@"C:\two\api"); + var a = PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "one", "api")); + var b = PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "two", "api")); - Assert.Equal(a, PlanCheckpointStore.PathFor(@"C:\one\api\")); // trailing separator + Assert.Equal(a, PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "one", "api") + Path.DirectorySeparatorChar)); // trailing separator Assert.NotEqual(a, b); // two folders called "api" Assert.Contains("api-", Path.GetFileName(a)); // readable leaf retained } @@ -111,11 +111,11 @@ public void PathIsStablePerProject_AndDistinguishesSameLeafNames() [Fact] public void DesktopAgentCheckpointPaths_DoNotCollideWithinOneProject() { - var first = PlanCheckpointStore.PathFor(@"C:\work\api", "agent-one"); - var second = PlanCheckpointStore.PathFor(@"C:\work\api", "agent-two"); + var first = PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "work", "api"), "agent-one"); + var second = PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "work", "api"), "agent-two"); Assert.NotEqual(first, second); - Assert.Equal(first, PlanCheckpointStore.PathFor(@"C:\work\api\", "agent-one")); - Assert.NotEqual(first, PlanCheckpointStore.PathFor(@"C:\other\api", "agent-one")); + Assert.Equal(first, PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "work", "api") + Path.DirectorySeparatorChar, "agent-one")); + Assert.NotEqual(first, PlanCheckpointStore.PathFor(Path.Combine(Path.GetTempPath(), "other", "api"), "agent-one")); } } diff --git a/tests/MandoCode.Tests/TuiLayoutTests.cs b/tests/MandoCode.Tests/TuiLayoutTests.cs index b3c7968..e6bd428 100644 --- a/tests/MandoCode.Tests/TuiLayoutTests.cs +++ b/tests/MandoCode.Tests/TuiLayoutTests.cs @@ -333,6 +333,8 @@ private static string Render(Spectre.Console.Rendering.IRenderable renderable, i { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.No, ColorSystem = ColorSystemSupport.NoColors }); + // CI can force ANSI even when settings request plain output. Pin the test profile. + console.Profile.Capabilities.Ansi = false; console.Profile.Width = width; console.Write(renderable); return writer.ToString(); diff --git a/tests/MandoCode.Tests/WorkspaceRegistryTests.cs b/tests/MandoCode.Tests/WorkspaceRegistryTests.cs index 39bd65f..2d59547 100644 --- a/tests/MandoCode.Tests/WorkspaceRegistryTests.cs +++ b/tests/MandoCode.Tests/WorkspaceRegistryTests.cs @@ -30,6 +30,8 @@ public void AsciiArt_ClipsEachRowWithoutWrapping_AndPreservesLiteralText(int wid var lines = new[] { " ███╗ ███╗ █████╗ ███╗ ██╗", "[literal] ░▒▓░░▒▓▒░░▒▓░░▒▓▒", " ▓░▒▓░░░▒▓░▒░░▓▒ v0.16.0" }; var writer = new StringWriter(); var console = Spectre.Console.AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.No, ColorSystem = ColorSystemSupport.NoColors }); + // CI can force ANSI even when settings request plain output. Pin the test profile. + console.Profile.Capabilities.Ansi = false; console.Profile.Width = width; console.Write(new AsciiArtRenderable(lines)); var output = writer.ToString().Replace("\r", "").TrimEnd('\n').Split('\n'); @@ -757,6 +759,8 @@ private static string Paint(IServiceProvider services, VNode node) var layout = new LayoutEngine().Layout(services.GetRequiredService().Translate(node), new BoxConstraints(width, width, 30, 30)); var writer = new StringWriter(); var console = Spectre.Console.AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.No, ColorSystem = ColorSystemSupport.NoColors }); + // CI can force ANSI even when settings request plain output. Pin the test profile. + console.Profile.Capabilities.Ansi = false; console.Profile.Width = width; console.Write(layout.PaintToRenderable()); return writer.ToString(); From 392d0e6e32ce77ef98c71c311f67cc11f1bc526d Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Fri, 9 Oct 2026 18:00:00 -0700 Subject: [PATCH 2/2] fix: isolate clipboard command tests from process stdout --- docs/CHANGELOG.md | 1 + .../Services/Rendering/TuiConsole.cs | 18 +++++++-- .../MandoCode.Tests/ClipboardCommandTests.cs | 38 +++++++++++++------ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 269485b..3d34e4e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to MandoCode will be documented in this file. ### Fixed - Make clipboard-reader overrides work across platforms while retaining native terminal paste on Linux when only the Windows clipboard reader is available. +- Isolate clipboard-command test output from process stdout and verify scoped terminal-control sinks across concurrent agents. - Stabilize CI layout checks under forced ANSI output, use native paths in checkpoint tests, and restore the process working directory after testing bare `cd` commands. - Complete pending menu/input requests when an agent closes, including model selection, directory selection, and plan approvals. diff --git a/src/MandoCode/Services/Rendering/TuiConsole.cs b/src/MandoCode/Services/Rendering/TuiConsole.cs index f96b7b5..8de0657 100644 --- a/src/MandoCode/Services/Rendering/TuiConsole.cs +++ b/src/MandoCode/Services/Rendering/TuiConsole.cs @@ -9,6 +9,7 @@ public static class TuiConsole { private static TextWriter? _terminalWriter; private static readonly AsyncLocal Ambient = new(); + private static readonly AsyncLocal ControlOutput = new(); private static TuiSession? _fallback; public static TuiSession? Current => Ambient.Value ?? _fallback; internal static void SetActive(TuiSession session) { if (_fallback is not null) _fallback = session; } @@ -16,7 +17,16 @@ public static IDisposable Enter(TuiSession session) { var previous = Ambient.Value; Ambient.Value = session; - return new SessionScope(previous); + return new SessionScope(previous, ControlOutput.Value); + } + // Scoped terminal sink lets isolated hosts/tests route OSC controls without replacing process stdout. + internal static IDisposable Enter(TuiSession session, TextWriter terminalOutput) + { + var previous = Ambient.Value; + var previousOutput = ControlOutput.Value; + Ambient.Value = session; + ControlOutput.Value = terminalOutput; + return new SessionScope(previous, previousOutput); } public static IDisposable Begin(TuiSession session) { @@ -34,7 +44,7 @@ public static IDisposable Begin(TuiSession session) // Send them to the retained terminal writer rather than the transcript sanitizer. internal static void WriteTerminalControl(string sequence) { - if (_terminalWriter is { } writer) writer.Write(sequence); + if ((ControlOutput.Value ?? _terminalWriter) is { } writer) writer.Write(sequence); else System.Console.Write(sequence); } public static IAnsiConsole Console { get => PhysicalConsole.Console; set => PhysicalConsole.Console = value; } @@ -62,9 +72,9 @@ public static void WriteSpaced(IRenderable content) public static T Ask(string prompt, T defaultValue) => PhysicalConsole.Ask(prompt, defaultValue); public static bool Confirm(string prompt, bool defaultValue = true) => PhysicalConsole.Confirm(prompt, defaultValue); public static Status Status() => PhysicalConsole.Status(); - private sealed class SessionScope(TuiSession? previous) : IDisposable + private sealed class SessionScope(TuiSession? previous, TextWriter? previousOutput) : IDisposable { - public void Dispose() => Ambient.Value = previous; + public void Dispose() { Ambient.Value = previous; ControlOutput.Value = previousOutput; } } private sealed class RoutedTranscriptWriter(TuiSession fallback) : TextWriter { diff --git a/tests/MandoCode.Tests/ClipboardCommandTests.cs b/tests/MandoCode.Tests/ClipboardCommandTests.cs index 0353755..b492e95 100644 --- a/tests/MandoCode.Tests/ClipboardCommandTests.cs +++ b/tests/MandoCode.Tests/ClipboardCommandTests.cs @@ -10,27 +10,45 @@ namespace MandoCode.Tests; [Trait("Category", "Component")] public class ClipboardCommandTests { + [Fact] + public async Task TerminalControlSinksAreIsolatedAndRestoreTheirParentScope() + { + using var parent = new StringWriter(); + using var first = new StringWriter(); + using var second = new StringWriter(); + using (TuiConsole.Enter(new TuiSession(), parent)) + { + await Task.WhenAll(Write(first, "first"), Write(second, "second")); + TuiConsole.WriteTerminalControl("parent"); + } + Assert.Equal("first", first.ToString()); + Assert.Equal("second", second.ToString()); + Assert.Equal("parent", parent.ToString()); + static async Task Write(TextWriter writer, string text) + { + using var scope = TuiConsole.Enter(new TuiSession(), writer); + await Task.Yield(); + TuiConsole.WriteTerminalControl(text); + } + } + [Theory] [InlineData("HandleCopyCommand", "Reply with Unicode: café 🚀", "Reply with Unicode: café 🚀")] [InlineData("HandleCopyCodeCommand", "First\n```cs\nvar name = \"café\";\n```\nThen\n```text\nsecond block 🚀\n```", "var name = \"café\";\n\nsecond block 🚀")] public void CopyCommands_SendClipboardSequenceToTerminal_AndOnlyConfirmationToTranscript(string method, string response, string copied) { - var previous = Console.Out; using var terminal = new StringWriter(); - Console.SetOut(terminal); - try { var session = new TuiSession(); - using var scope = TuiConsole.Begin(session); + using var scope = TuiConsole.Enter(session, terminal); var app = new App(); typeof(App).GetField("_lastAiResponse", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(app, response); typeof(App).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(app, null); - Console.Out.Flush(); + terminal.Flush(); var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(copied)); Assert.Equal($"\u001b]52;c;{payload}\u0007", terminal.ToString()); Assert.Single(session.Snapshot().Entries); } - finally { Console.SetOut(previous); } } [Theory] @@ -39,20 +57,16 @@ public void CopyCommands_SendClipboardSequenceToTerminal_AndOnlyConfirmationToTr [InlineData("HandleCopyCodeCommand", "Reply without code fences")] public void CopyCommands_WithNothingToCopy_DoNotSendClipboardSequence(string method, string? response) { - var previous = Console.Out; using var terminal = new StringWriter(); - Console.SetOut(terminal); - try { var session = new TuiSession(); - using var scope = TuiConsole.Begin(session); + using var scope = TuiConsole.Enter(session, terminal); var app = new App(); typeof(App).GetField("_lastAiResponse", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(app, response); typeof(App).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(app, null); - Console.Out.Flush(); + terminal.Flush(); Assert.Equal("", terminal.ToString()); Assert.Single(session.Snapshot().Entries); } - finally { Console.SetOut(previous); } } }