diff --git a/CHANGELOG.md b/CHANGELOG.md index 1790c2b..8b75459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **Watch the agent's shell commands run.** The terminal panel gains a read-only tab per agent + showing every command that agent runs — the command and the folder it runs in, its output line by + line as it arrives, and whether it finished, failed, or was killed for taking too long. A long + build is no longer ninety seconds of silence. The tab is deliberately read-only: the agent's + commands still run through captured pipes rather than a terminal, so nothing about what the model + receives, how commands are timed out, or how they report their exit code changes. The view keeps + more scrollback than the model is given, so the tail of a long build is visible even though the + model's copy is truncated. Output is recorded from launch, so opening the panel after a build + still shows it. A tab never steals focus while you are working in a shell — its title accents + instead — and closing one discards that agent's recorded output. The terminal button on the rail + carries a dot when an agent has produced output you have not seen, so the tab is discoverable + without opening the panel to find it: the dot pulses while a command is actually running and goes + still once it finishes, and following it opens the panel directly on that agent's output rather + than on an empty shell. Themes that switch off motion get the still dot in both cases. - **PDFs open in the preview pane.** Selecting a PDF in the Explorer shows it in the browser's own viewer — scroll, zoom, search, print — instead of only offering to open it in another application. This is for reading: a PDF's text and structure are not reachable through the page diff --git a/MandoCode b/MandoCode index fe8eb6d..f4e0556 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit fe8eb6d897c8c8ffd0652e91ea6f7973d9f7f8b6 +Subproject commit f4e055692d6de39c29b7a80dc17d3c6e4503c559 diff --git a/src/MandoCode.Desktop.Tests/AgentCommandOutputTests.cs b/src/MandoCode.Desktop.Tests/AgentCommandOutputTests.cs new file mode 100644 index 0000000..96f299a --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentCommandOutputTests.cs @@ -0,0 +1,220 @@ +using System.Text.RegularExpressions; +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The agent output tab is a read-only mirror of what the agent runs. These cover the two host-side +/// pieces that decide what actually reaches xterm: how a command's lifecycle is rendered, and how +/// the buffer that survives until someone opens the panel is kept. +/// +public class AgentCommandOutputTests +{ + // \u001b, not \x1b: C# hex escapes are variable-length and greedily eat any hex + // digit that follows, so "\x1b" next to one silently becomes a different character. + private const char Esc = '\u001b'; + + /// Complete SGR colour sequences, removed so what remains can be checked for damage. + private static string StripSgr(string s) => Regex.Replace(s, @"\u001b\[[0-9;]*m", ""); + + // ---- Formatting ------------------------------------------------------------- + + [Fact] + public void HeaderShowsTheCommandAndWhereItRuns() + { + // The folder matters: an agent runs in ITS project root, which is not necessarily the one + // the user is looking at, and two agents share this panel. + var header = AgentCommandFormat.Header("dotnet build", @"C:\src\project"); + + Assert.Contains("dotnet build", StripSgr(header)); + Assert.Contains(@"C:\src\project", StripSgr(header)); + } + + [Fact] + public void StderrIsDistinguishableFromStdout() + { + var stdout = AgentCommandFormat.Line("all good", isError: false); + var stderr = AgentCommandFormat.Line("all good", isError: true); + + Assert.NotEqual(stdout, stderr); + Assert.Equal("all good\r\n", stdout); // no decoration on the common case + Assert.Equal("all good", StripSgr(stderr).TrimEnd('\r', '\n')); + } + + [Fact] + public void SuccessFailureAndKillReadDifferently() + { + // A non-zero exit means the command ran and disagreed with you; a kill means it never got + // to finish. Someone watching has to be able to tell those apart at a glance. + var ok = StripSgr(AgentCommandFormat.Footer(0, null)); + var failed = StripSgr(AgentCommandFormat.Footer(1, null)); + var killed = StripSgr(AgentCommandFormat.Footer(null, "idle 30s with no output")); + + Assert.Contains("exit 0", ok); + Assert.Contains("exit 1", failed); + Assert.Contains("killed", killed); + Assert.Contains("idle 30s with no output", killed); + Assert.NotEqual(ok, failed); + } + + [Fact] + public void CommandOutputCannotDriveTheDisplay() + { + // A tool that emits VT even when it is not talking to a terminal could otherwise clear the + // screen or move the cursor, wiping the log someone is reading. The escape is defanged, + // and the visible text is kept. + var line = AgentCommandFormat.Line("\u001b[2Jwiped\u001b[Hagain", isError: false); + + Assert.DoesNotContain(Esc, (IEnumerable)line); + Assert.Contains("wiped", line); + Assert.Contains("again", line); + } + + [Fact] + public void OneLineOfOutputStaysOneLine() + { + // Embedded newlines would let a single line forge the header/footer structure around it, + // and would desynchronise the display from the line count the model was given. + var line = AgentCommandFormat.Line("first\nsecond\rthird", isError: false); + + Assert.Equal("firstsecondthird\r\n", line); + } + + [Fact] + public void TabsSurviveBecauseTheyAreAlignment() + { + Assert.Equal("a\tb\r\n", AgentCommandFormat.Line("a\tb", isError: false)); + } + + // ---- Buffering -------------------------------------------------------------- + + [Fact] + public void RecordsForAViewThatIsNotOpenYet() + { + // The whole reason the log buffers: the terminal panel is built lazily, so an agent that + // builds before the user opens it must still have something to show. + var log = new AgentCommandLog(); + + log.CommandStarted("git status", @"C:\src"); + log.CommandOutput("nothing to commit", isError: false); + log.CommandFinished(0, null); + + var snapshot = StripSgr(log.Snapshot()); + Assert.Contains("git status", snapshot); + Assert.Contains("nothing to commit", snapshot); + Assert.Contains("exit 0", snapshot); + } + + [Fact] + public void LiveSubscribersSeeExactlyWhatIsBuffered() + { + var log = new AgentCommandLog(); + var live = ""; + log.Appended += text => live += text; + + log.CommandStarted("ls", @"C:\src"); + log.CommandOutput("file.txt", isError: false); + log.CommandFinished(0, null); + + Assert.Equal(log.Snapshot(), live); + } + + // ---- Running state (drives the rail's pulse) -------------------------------- + + [Fact] + public void RunningIsTrueOnlyBetweenStartAndFinish() + { + var log = new AgentCommandLog(); + Assert.False(log.IsRunning); + + log.CommandStarted("dotnet build", @"C:\src"); + Assert.True(log.IsRunning); + + log.CommandFinished(0, null); + Assert.False(log.IsRunning); + } + + [Fact] + public void OverlappingCommandsStayRunningUntilTheLastOneEnds() + { + // Counted, not a flag: a plan step can have one command in flight while another closes out, + // and a flag would report idle — stopping the pulse — while work was still going. + var log = new AgentCommandLog(); + + log.CommandStarted("first", @"C:\src"); + log.CommandStarted("second", @"C:\src"); + log.CommandFinished(0, null); + + Assert.True(log.IsRunning); + log.CommandFinished(0, null); + Assert.False(log.IsRunning); + } + + [Fact] + public void RunningChangedFiresOnlyOnRealTransitions() + { + var log = new AgentCommandLog(); + var seen = new List(); + log.RunningChanged += running => seen.Add(running); + + log.CommandStarted("first", @"C:\src"); + log.CommandStarted("second", @"C:\src"); + log.CommandFinished(0, null); + log.CommandFinished(0, null); + + Assert.Equal(new[] { true, false }, seen); + } + + [Fact] + public void AnUnbalancedFinishCannotLeaveTheRailPulsingForever() + { + // The sink contract pairs every finish with a start, but the rail animates off this state, + // so a stray call must not drive the counter negative and wedge it "running". + var log = new AgentCommandLog(); + + log.CommandFinished(0, null); + log.CommandStarted("later", @"C:\src"); + log.CommandFinished(0, null); + + Assert.False(log.IsRunning); + } + + [Fact] + public void ClearDropsWhatTheUserDismissed() + { + var log = new AgentCommandLog(); + log.CommandOutput("old news", isError: false); + + log.Clear(); + + Assert.Equal("", log.Snapshot()); + } + + [Fact] + public void BufferStaysBoundedAndKeepsTheNewestOutput() + { + var log = new AgentCommandLog(); + for (int i = 0; i < 4000; i++) + log.CommandOutput($"line {i} " + new string('x', 100), isError: false); + + var snapshot = log.Snapshot(); + Assert.True(snapshot.Length <= AgentCommandLog.MaxBufferedChars, + $"buffer grew to {snapshot.Length}"); + Assert.Contains("line 3999", snapshot); + Assert.DoesNotContain("line 0 ", snapshot); + } + + [Fact] + public void TrimmingNeverLeavesHalfAnEscapeSequence() + { + // Cutting at an exact character count would routinely land mid-sequence, and half an SGR + // code replayed into xterm colours everything after it until something else resets. The + // buffer is trimmed to a line boundary to avoid that. + var log = new AgentCommandLog(); + for (int i = 0; i < 4000; i++) + log.CommandOutput($"error {i} " + new string('e', 100), isError: true); // every line coloured + + Assert.DoesNotContain(Esc, (IEnumerable)StripSgr(log.Snapshot())); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index ac3d4c8..965e803 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -58,6 +58,8 @@ + + diff --git a/src/MandoCode.Desktop/Services/AgentCommandFormat.cs b/src/MandoCode.Desktop/Services/AgentCommandFormat.cs new file mode 100644 index 0000000..eb2305b --- /dev/null +++ b/src/MandoCode.Desktop/Services/AgentCommandFormat.cs @@ -0,0 +1,71 @@ +namespace MandoCode.Desktop.Services; + +/// +/// Renders an agent shell command's lifecycle as terminal text — a prompt-style header, the +/// command's own lines, and a closing status. Kept separate from +/// and free of any UI type so the exact bytes that reach xterm can be asserted in tests. +/// +/// Colors are set with SGR escapes rather than the app's theme brushes because this text is +/// written into an xterm buffer, which knows nothing about XAML resources. The 256-color codes +/// chosen here match the terminal theme in terminal.js. +/// +public static class AgentCommandFormat +{ + private const string Dim = "\x1b[38;5;244m"; + private const string Gold = "\x1b[38;5;214m"; + private const string Red = "\x1b[38;5;203m"; + private const string Green = "\x1b[38;5;114m"; + private const string Reset = "\x1b[0m"; + + /// + /// The line announcing a command, styled like a shell prompt so the log reads the way a + /// terminal session does. The working directory is shown because an agent's commands run in + /// its own project root, which is not necessarily the folder the user is looking at. + /// + public static string Header(string command, string workingDirectory) => + $"\r\n{Dim}{Sanitize(workingDirectory)}{Reset}\r\n{Gold}${Reset} {Sanitize(command)}\r\n"; + + /// + /// One line of command output. stderr is colored rather than prefixed: the model's copy uses an + /// "[err]" prefix because it reads plain text, but a person watching a terminal reads color + /// faster and the prefix would just eat width. + /// + public static string Line(string line, bool isError) => + isError ? $"{Red}{Sanitize(line)}{Reset}\r\n" : $"{Sanitize(line)}\r\n"; + + /// + /// How the command ended. A non-zero exit and a kill are shown differently on purpose — one + /// means the command ran and disagreed with you, the other means it never got to finish. + /// + public static string Footer(int? exitCode, string? killReason) + { + if (killReason != null) + return $"{Gold}■ killed: {Sanitize(killReason)}{Reset}\r\n"; + if (exitCode == 0) + return $"{Green}✓ exit 0{Reset}\r\n"; + return $"{Red}✗ exit {exitCode}{Reset}\r\n"; + } + + /// + /// Strips control characters that would let a command's output drive the display rather than + /// appear in it — a stray "clear screen" or cursor-home from a tool that emits VT even when it + /// is not talking to a terminal would otherwise wipe the log someone is reading. Tabs are kept + /// (they are alignment, and every compiler emits them); ESC is neutered so no sequence can + /// start. Note this is display hygiene, not a security boundary: the same text has already + /// gone to the model verbatim. + /// + private static string Sanitize(string text) + { + if (string.IsNullOrEmpty(text)) return ""; + Span buffer = text.Length <= 512 ? stackalloc char[text.Length] : new char[text.Length]; + int n = 0; + foreach (var c in text) + { + if (c == '\t') { buffer[n++] = c; continue; } + if (c == '\x1b') { buffer[n++] = '^'; continue; } + if (char.IsControl(c)) continue; // CR/LF included: this renders exactly one line + buffer[n++] = c; + } + return new string(buffer[..n]); + } +} diff --git a/src/MandoCode.Desktop/Services/AgentCommandLog.cs b/src/MandoCode.Desktop/Services/AgentCommandLog.cs new file mode 100644 index 0000000..71d5e1b --- /dev/null +++ b/src/MandoCode.Desktop/Services/AgentCommandLog.cs @@ -0,0 +1,113 @@ +using System.Text; +using MandoCode.Services; + +namespace MandoCode.Desktop.Services; + +/// +/// One agent's shell-command activity, kept as terminal-ready text so the terminal panel can show +/// it live. Attached to that agent's as an +/// ; the engine calls into it as commands run. +/// +/// Why this buffers rather than only forwarding: the terminal panel is built lazily, on first +/// open. Without a buffer, an agent that ran a build before the user opened the panel would show an +/// empty tab — precisely the case the feature exists for. Whoever attaches replays +/// first, then follows . +/// +/// Threading: the engine raises output on the command's reader threads, so every +/// member here is under one lock and fires on whatever thread produced the +/// output. Subscribers marshal to the UI thread themselves. +/// +public sealed class AgentCommandLog : ICommandOutputSink +{ + /// + /// Retained scrollback, in characters. Generous because it holds full build output that the + /// model's own copy truncates, and bounded because a watch loop or a chatty test run would + /// otherwise grow it without limit for as long as the agent lives. + /// + public const int MaxBufferedChars = 256 * 1024; + + private readonly object _lock = new(); + private readonly StringBuilder _buffer = new(); + + private int _running; + + /// Fresh terminal text, already formatted. Raised on arbitrary threads. + public event Action? Appended; + + /// Raised when flips. Raised on arbitrary threads. + public event Action? RunningChanged; + + /// + /// True while at least one command is in flight. Lets the rail distinguish "work is happening + /// right now" from "output is sitting here unread" — the same badge means both, and only the + /// first deserves motion. + /// + public bool IsRunning => Volatile.Read(ref _running) > 0; + + public void CommandStarted(string command, string workingDirectory) + { + // Counted rather than a bool: a plan step can have a command in flight while another is + // still closing out, and a bool would report idle the moment the first one finished. + if (Interlocked.Increment(ref _running) == 1) RunningChanged?.Invoke(true); + Append(AgentCommandFormat.Header(command, workingDirectory)); + } + + public void CommandOutput(string line, bool isError) => + Append(AgentCommandFormat.Line(line, isError)); + + public void CommandFinished(int? exitCode, string? killReason) + { + Append(AgentCommandFormat.Footer(exitCode, killReason)); + // Clamped: a sink is only ever finished once per start, but an unbalanced call must not + // drive the counter negative and leave the rail pulsing forever. + if (Interlocked.Decrement(ref _running) <= 0) + { + Interlocked.Exchange(ref _running, 0); + RunningChanged?.Invoke(false); + } + } + + /// Everything retained so far — replayed into a view that attaches late. + public string Snapshot() + { + lock (_lock) { return _buffer.ToString(); } + } + + /// Drops the retained scrollback. Used when the user closes the agent's output tab, + /// so reopening it later starts clean rather than replaying what they dismissed. + public void Clear() + { + lock (_lock) { _buffer.Clear(); } + } + + private void Append(string text) + { + lock (_lock) + { + _buffer.Append(text); + if (_buffer.Length > MaxBufferedChars) TrimOldest(); + } + Appended?.Invoke(text); + } + + /// + /// Discards from the front, then advances to just past the next newline. Cutting at the exact + /// character count would routinely land mid-escape-sequence, and half an SGR code replayed into + /// xterm colors everything after it until something else resets — so the buffer is trimmed to a + /// line boundary even though that drops a little more than strictly necessary. + /// + private void TrimOldest() + { + var excess = _buffer.Length - MaxBufferedChars; + for (int i = excess; i < _buffer.Length; i++) + { + if (_buffer[i] == '\n') + { + _buffer.Remove(0, i + 1); + return; + } + } + // One line longer than the whole budget: no boundary to keep, so start over. + _buffer.Clear(); + } +} diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index 07e8e48..ff47cc2 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -65,6 +65,10 @@ public string Title public TokenTrackingService Tokens { get; } public PlanHandoff PlanHandoff { get; } public SkillLoader Skills { get; } + + /// This agent's shell-command activity, as terminal-ready text. Always recording, so + /// the terminal panel can show work that ran before the user opened it. + public AgentCommandLog CommandLog { get; } public McpApprovalGate McpGate { get; } public AIService Ai { get; } public TaskPlannerService Planner { get; } @@ -117,7 +121,11 @@ public AgentSession( Skills = new SkillLoader(Config, ProjectRoot); McpGate = new McpApprovalGate(Config); - Ai = new AIService(ProjectRoot, Config, Tokens, PlanHandoff, Skills, mcpManager, McpGate, spinner); + // Attached once, here: the engine hands the sink to the filesystem plugin on every agent + // rebuild, so it survives model switches, settings changes, and folder changes without the + // host re-attaching anything. + CommandLog = new AgentCommandLog(); + Ai = new AIService(ProjectRoot, Config, Tokens, PlanHandoff, Skills, mcpManager, McpGate, spinner, CommandLog); PreviewTools = new DesktopPreviewTools(ProjectRoot) { RequireTabId = true, ImageSink = new AgentImageSink(new AiServiceAdapter(Ai)) }; Ai.SetHostTools([ Microsoft.Extensions.AI.AIFunctionFactory.Create(PreviewTools.ListBrowserFrames, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "list_browser_frames" }),