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" }),