From bbe84d40494ac8540e9457faf67b4bc66cc33dcb Mon Sep 17 00:00:00 2001 From: DevMando Date: Wed, 9 Sep 2026 20:55:14 -0700 Subject: [PATCH 1/2] Show agent shell commands in a read-only terminal tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's commands were invisible while they ran: a long build was a single truncated status line for its whole duration, then a wall of finished output. Give each agent a read-only tab in the terminal panel, fed by the engine's new command output sink. Deliberately NOT a shared pseudo-terminal: the agent keeps running commands through captured pipes, which is what keeps stdout and stderr separate, keeps the output free of escape sequences, and leaves a real exit code to report. Routing the agent through a PTY to make it visible would trade all three away for cursor control the model has no use for, plus a second writer racing the user for one stdin. This is a fan-out of lines that already existed, so it costs the agent nothing. AgentCommandLog buffers per agent from launch, because the terminal panel is built lazily — without it, an agent that built before the panel was ever opened would show an empty tab, which is the case the feature exists for. It holds more than the model's 5000-character copy, so the tail of a long build stays visible. Trimming lands on a line boundary: cutting mid-escape would leave half an SGR code to colour everything after it. AgentCommandFormat renders the lifecycle and strips control characters from command output, so a tool that emits VT when it isn't talking to a terminal can't clear the log someone is reading. Tabs never steal focus — an agent building while you type in a shell must not yank the panel away — so the title accents instead. Closing a tab clears that agent's buffer, so it isn't replayed on the next command. --- CHANGELOG.md | 10 ++ MandoCode | 2 +- .../AgentCommandOutputTests.cs | 160 ++++++++++++++++++ .../MandoCode.Desktop.Tests.csproj | 4 +- .../Assets/web/terminal/terminal.js | 37 ++-- .../Controls/TerminalPanel.xaml.cs | 117 +++++++++++-- src/MandoCode.Desktop/MainWindow.Tabs.cs | 6 +- src/MandoCode.Desktop/MainWindow.Terminal.cs | 55 +++++- .../Services/AgentCommandFormat.cs | 71 ++++++++ .../Services/AgentCommandLog.cs | 87 ++++++++++ .../Services/AgentSession.cs | 10 +- 11 files changed, 533 insertions(+), 26 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/AgentCommandOutputTests.cs create mode 100644 src/MandoCode.Desktop/Services/AgentCommandFormat.cs create mode 100644 src/MandoCode.Desktop/Services/AgentCommandLog.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1790c2b..3b63e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ 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. - **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..392cff8 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentCommandOutputTests.cs @@ -0,0 +1,160 @@ +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); + } + + [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/AgentCommandLog.cs b/src/MandoCode.Desktop/Services/AgentCommandLog.cs index de2c5b7..71d5e1b 100644 --- a/src/MandoCode.Desktop/Services/AgentCommandLog.cs +++ b/src/MandoCode.Desktop/Services/AgentCommandLog.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using MandoCode.Services; namespace MandoCode.Desktop.Services; @@ -29,17 +29,43 @@ public sealed class AgentCommandLog : ICommandOutputSink 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; - public void CommandStarted(string command, string workingDirectory) => + /// 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) => + 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()