Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ 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.
- 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.
Expand Down
7 changes: 4 additions & 3 deletions src/MandoCode/Components/PromptInput.razor
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,14 @@
private async Task<bool> 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) {
Expand Down
18 changes: 14 additions & 4 deletions src/MandoCode/Services/Rendering/TuiConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ public static class TuiConsole
{
private static TextWriter? _terminalWriter;
private static readonly AsyncLocal<TuiSession?> Ambient = new();
private static readonly AsyncLocal<TextWriter?> 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; }
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)
{
Expand All @@ -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; }
Expand Down Expand Up @@ -62,9 +72,9 @@ public static void WriteSpaced(IRenderable content)
public static T Ask<T>(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
{
Expand Down
38 changes: 26 additions & 12 deletions tests/MandoCode.Tests/ClipboardCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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); }
}
}
5 changes: 4 additions & 1 deletion tests/MandoCode.Tests/CommandOutputSinkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[Collection("TUI console routing")]
[Trait("Category", "Integration")]
public class CommandOutputSinkTests : IDisposable
{
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions tests/MandoCode.Tests/MarkdownHtmlRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 3 additions & 2 deletions tests/MandoCode.Tests/PlanCheckpointEnvelopeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 7 additions & 7 deletions tests/MandoCode.Tests/PlanCheckpointStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,22 +100,22 @@ 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
}

[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"));
}
}
2 changes: 2 additions & 0 deletions tests/MandoCode.Tests/TuiLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions tests/MandoCode.Tests/WorkspaceRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -757,6 +759,8 @@ private static string Paint(IServiceProvider services, VNode node)
var layout = new LayoutEngine().Layout(services.GetRequiredService<WidgetTranslationContext>().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();
Expand Down
Loading